From 0a2f5ef50892ebcb7fd4592eab85666ac894cc0d Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Tue, 23 Jun 2026 16:37:26 -0700 Subject: [PATCH 01/24] feat: add Rust control plane foundation --- .gitignore | 7 +- Cargo.lock | 2547 ++++++++++++++++++++++++ Cargo.toml | 33 + bun.lock | 1001 +++++++++- docs/architecture.md | 85 + migrations/20260623000000_initial.sql | 45 + package.json | 3 +- src/api.rs | 1090 ++++++++++ src/crypto.rs | 429 ++++ src/database.rs | 286 +++ src/lib.rs | 165 ++ src/main.rs | 94 + src/web_assets.rs | 10 + tests/config.rs | 68 + tests/control_plane.rs | 1174 +++++++++++ tests/crypto.rs | 61 + web/.gitignore | 23 + web/.npmrc | 1 + web/.prettierignore | 9 + web/.prettierrc | 7 + web/.vscode/extensions.json | 3 + web/README.md | 29 + web/package.json | 33 + web/src/app.d.ts | 5 + web/src/app.html | 12 + web/src/lib/DashboardShell.svelte | 99 + web/src/lib/ErrorNotice.svelte | 14 + web/src/lib/PlaceholderPanel.svelte | 20 + web/src/lib/api.test.ts | 153 ++ web/src/lib/api.ts | 254 +++ web/src/lib/assets/favicon.svg | 6 + web/src/lib/auth.svelte.ts | 196 ++ web/src/lib/auth.test.ts | 183 ++ web/src/lib/clipboard.test.ts | 44 + web/src/lib/clipboard.ts | 24 + web/src/lib/index.ts | 1 + web/src/lib/navigation.test.ts | 55 + web/src/lib/navigation.ts | 58 + web/src/lib/token-page-state.test.ts | 30 + web/src/lib/token-page-state.ts | 25 + web/src/lib/token-reveal-focus.test.ts | 57 + web/src/lib/token-reveal-focus.ts | 17 + web/src/routes/+layout.svelte | 82 + web/src/routes/+layout.ts | 1 + web/src/routes/+page.svelte | 9 + web/src/routes/approvals/+page.svelte | 15 + web/src/routes/login/+page.svelte | 73 + web/src/routes/logs/+page.svelte | 15 + web/src/routes/setup/+page.svelte | 122 ++ web/src/routes/setup/page.test.ts | 12 + web/src/routes/sources/+page.svelte | 15 + web/src/routes/tokens/+page.svelte | 369 ++++ web/src/routes/tools/+page.svelte | 15 + web/src/styles.css | 838 ++++++++ web/static/robots.txt | 3 + web/svelte.config.js | 11 + web/tsconfig.json | 14 + web/vite.config.ts | 11 + 58 files changed, 10024 insertions(+), 37 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 docs/architecture.md create mode 100644 migrations/20260623000000_initial.sql create mode 100644 src/api.rs create mode 100644 src/crypto.rs create mode 100644 src/database.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/web_assets.rs create mode 100644 tests/config.rs create mode 100644 tests/control_plane.rs create mode 100644 tests/crypto.rs create mode 100644 web/.gitignore create mode 100644 web/.npmrc create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc create mode 100644 web/.vscode/extensions.json create mode 100644 web/README.md create mode 100644 web/package.json create mode 100644 web/src/app.d.ts create mode 100644 web/src/app.html create mode 100644 web/src/lib/DashboardShell.svelte create mode 100644 web/src/lib/ErrorNotice.svelte create mode 100644 web/src/lib/PlaceholderPanel.svelte create mode 100644 web/src/lib/api.test.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/assets/favicon.svg create mode 100644 web/src/lib/auth.svelte.ts create mode 100644 web/src/lib/auth.test.ts create mode 100644 web/src/lib/clipboard.test.ts create mode 100644 web/src/lib/clipboard.ts create mode 100644 web/src/lib/index.ts create mode 100644 web/src/lib/navigation.test.ts create mode 100644 web/src/lib/navigation.ts create mode 100644 web/src/lib/token-page-state.test.ts create mode 100644 web/src/lib/token-page-state.ts create mode 100644 web/src/lib/token-reveal-focus.test.ts create mode 100644 web/src/lib/token-reveal-focus.ts create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+layout.ts create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/approvals/+page.svelte create mode 100644 web/src/routes/login/+page.svelte create mode 100644 web/src/routes/logs/+page.svelte create mode 100644 web/src/routes/setup/+page.svelte create mode 100644 web/src/routes/setup/page.test.ts create mode 100644 web/src/routes/sources/+page.svelte create mode 100644 web/src/routes/tokens/+page.svelte create mode 100644 web/src/routes/tools/+page.svelte create mode 100644 web/src/styles.css create mode 100644 web/static/robots.txt create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 4c3f79186..ba8eb2cde 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ coverage *.lcov # logs -logs +/logs/ _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json @@ -115,3 +115,8 @@ scratch/ # Throwaway UX prototype (not part of the app) ux-demo/ + + +# Added by cargo + +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..a5892cd7b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2547 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +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 = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +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 = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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 = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "executor" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "base64", + "chacha20poly1305", + "clap", + "directories", + "hkdf", + "hmac", + "http-body-util", + "ipnet", + "rand", + "serde", + "serde_json", + "sha2", + "sqlx", + "subtle", + "tempfile", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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-io", + "futures-sink", + "futures-task", + "memchr", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[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 = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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 = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[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 = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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 = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "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.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +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_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +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-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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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 = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[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.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = [ + "digest", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +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" + +[[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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "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-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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[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 = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +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/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..ece8cb15a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "executor" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +argon2 = "0.5" +axum = "0.8" +base64 = "0.22" +chacha20poly1305 = "0.10" +clap = { version = "4", features = ["derive", "env"] } +directories = "6" +hkdf = "0.12" +hmac = "0.12" +ipnet = "2.12.0" +rand = "0.8.5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "migrate", "macros"] } +subtle = "2" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "fs", "sync"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" +uuid = { version = "1", features = ["v4", "serde"] } + +[dev-dependencies] +http-body-util = "0.1" +tempfile = "3" +tower = { version = "0.5", features = ["util"] } diff --git a/bun.lock b/bun.lock index 720667976..c565064c9 100644 --- a/bun.lock +++ b/bun.lock @@ -1162,6 +1162,27 @@ "vite": "catalog:", }, }, + "web": { + "name": "@executor-js/web", + "version": "0.1.0", + "devDependencies": { + "@effect/vitest": "4.0.0-beta.59", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.67.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@testing-library/svelte": "^5.4.2", + "effect": "4.0.0-beta.59", + "jsdom": "^29.1.1", + "oxlint": "^1.71.0", + "prettier": "^3.8.4", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.56.4", + "svelte-check": "^4.7.0", + "typescript": "^6.0.3", + "vite": "^8.1.0", + "vitest": "^4.1.9", + }, + }, }, "patchedDependencies": { "libsql@0.5.29": "patches/libsql@0.5.29.patch", @@ -1240,6 +1261,14 @@ "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@astrojs/cloudflare": ["@astrojs/cloudflare@13.1.10", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/underscore-redirects": "1.0.3", "@cloudflare/vite-plugin": "^1.25.6", "piccolore": "^0.1.3", "tinyglobby": "^0.2.15", "vite": "^7.3.1" }, "peerDependencies": { "astro": "^6.0.0", "wrangler": "^4.61.1" } }, "sha512-Ogl8p4MifPMHbpHKJL78eqEL+I3wbHrYmo83P/FkKZQ9VzzKi2A1RjnbaiiPAwrA8xQOqIUo4zlN7+Mse0aJAQ=="], "@astrojs/compiler": ["@astrojs/compiler@3.0.1", "", {}, "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA=="], @@ -1368,6 +1397,8 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], @@ -1446,6 +1477,18 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.8", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.5", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], @@ -1514,11 +1557,11 @@ "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], - "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="], @@ -1698,6 +1741,10 @@ "@executor-js/vite-plugin": ["@executor-js/vite-plugin@workspace:packages/core/vite-plugin"], + "@executor-js/web": ["@executor-js/web@workspace:web"], + + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], "@fastify/otel": ["@fastify/otel@0.18.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="], @@ -2338,6 +2385,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -2586,35 +2635,35 @@ "@rhyssul/portless": ["@rhyssul/portless@0.13.3", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-oPvwXJIIkRg2i1CUEUsz8sAdFSf0Fzzv+NmaacAsi6ZZTHxDOofbO6fGInVnbESIFOu4k8a/rXOZRF0aqLAUgw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.2", "", { "os": "android", "cpu": "arm64" }, "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.2", "", { "os": "none", "cpu": "arm64" }, "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.2", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA=="], "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], @@ -2742,6 +2791,16 @@ "@stitches/react": ["@stitches/react@1.2.8", "", { "peerDependencies": { "react": ">= 16.3.0" } }, "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.67.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JXHbsDwRes1Wgyof3q5ApzzpbCWvinKXMQCiV67TFO6xlZPYLoK0fq3xQMqSicDMgCtFGqLkrQXkseOcASdZ8A=="], + + "@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="], + "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], @@ -2820,6 +2879,12 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/svelte": ["@testing-library/svelte@5.4.2", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.1.3" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ=="], + + "@testing-library/svelte-core": ["@testing-library/svelte-core@1.1.3", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], @@ -2836,6 +2901,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -2852,6 +2919,8 @@ "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -3174,6 +3243,8 @@ "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -3470,6 +3541,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], @@ -3482,6 +3555,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -3494,6 +3569,8 @@ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -3546,6 +3623,8 @@ "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -3622,7 +3701,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], @@ -3664,8 +3743,12 @@ "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esrap": ["esrap@2.2.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw=="], + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], @@ -3912,6 +3995,8 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -4044,8 +4129,12 @@ "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], @@ -4094,6 +4183,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -4132,7 +4223,7 @@ "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], - "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "knip": ["knip@6.4.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw=="], @@ -4188,6 +4279,8 @@ "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4238,6 +4331,8 @@ "lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "macos-version": ["macos-version@6.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4570,7 +4665,7 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -4658,7 +4753,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], @@ -4682,7 +4777,11 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], + + "prettier-plugin-svelte": ["prettier-plugin-svelte@4.1.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^5.0.0" } }, "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], @@ -4938,7 +5037,7 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], + "rolldown": ["rolldown@1.1.2", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.2", "@rolldown/binding-darwin-arm64": "1.1.2", "@rolldown/binding-darwin-x64": "1.1.2", "@rolldown/binding-freebsd-x64": "1.1.2", "@rolldown/binding-linux-arm-gnueabihf": "1.1.2", "@rolldown/binding-linux-arm64-gnu": "1.1.2", "@rolldown/binding-linux-arm64-musl": "1.1.2", "@rolldown/binding-linux-ppc64-gnu": "1.1.2", "@rolldown/binding-linux-s390x-gnu": "1.1.2", "@rolldown/binding-linux-x64-gnu": "1.1.2", "@rolldown/binding-linux-x64-musl": "1.1.2", "@rolldown/binding-openharmony-arm64": "1.1.2", "@rolldown/binding-wasm32-wasi": "1.1.2", "@rolldown/binding-win32-arm64-msvc": "1.1.2", "@rolldown/binding-win32-x64-msvc": "1.1.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ=="], "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], @@ -4968,6 +5067,8 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -4976,6 +5077,8 @@ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], @@ -5038,6 +5141,8 @@ "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -5136,10 +5241,16 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], + + "svelte-check": ["svelte-check@4.7.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-XChCqdDg29UWOWai2I1s2Ss003piZ9mae2y2KXwoOX01W75mg7/fwLLnx9Yruk9asHkxHPDdSfEfsJh5tr9JvQ=="], + "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], @@ -5218,7 +5329,11 @@ "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -5350,7 +5465,7 @@ "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="], - "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], @@ -5368,6 +5483,8 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -5378,15 +5495,15 @@ "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], @@ -5416,6 +5533,8 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -5424,6 +5543,8 @@ "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], @@ -5456,6 +5577,8 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -5556,8 +5679,24 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@executor-js/api/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/cli/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/cloud/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/cloudflare/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/codemode-core/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/config/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/desktop/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@executor-js/e2e/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@executor-js/e2e/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/emulate/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], @@ -5566,8 +5705,24 @@ "@executor-js/example-promise-sdk/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "@executor-js/execution/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/fumadb/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/host-cloudflare/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/host-mcp/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/host-selfhost/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/integrations-registry/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/ir/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/local/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@executor-js/mcporter/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/mcporter/rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], @@ -5576,6 +5731,46 @@ "@executor-js/motel/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + "@executor-js/plugin-encrypted-secrets/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-example/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-file-secrets/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-google/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-graphql/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-keychain/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-mcp/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-microsoft/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-onepassword/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-openapi/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/plugin-workos-vault/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/runtime-deno-subprocess/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/runtime-dynamic-worker/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/runtime-quickjs/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/sdk/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/test-servers/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/vite-plugin/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@executor-js/web/oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="], + + "@executor-js/web/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "@executor-js/web/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@fastify/otel/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], "@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="], @@ -5586,6 +5781,8 @@ "@graphql-tools/schema/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -5766,8 +5963,6 @@ "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -5870,6 +6065,8 @@ "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@sentry/cloudflare/@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="], @@ -5886,6 +6083,10 @@ "@sentry/react/@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="], + "@sveltejs/kit/cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "@sveltejs/kit/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -5906,6 +6107,8 @@ "@tanstack/router-generator/@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="], + "@tanstack/router-generator/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@tanstack/router-plugin/@tanstack/router-generator": ["@tanstack/router-generator@1.166.32", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.15", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "magic-string": "^0.30.21", "prettier": "^3.5.0", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-VuusKwEXcgKq+myq1JQfZogY8scTXIIeFls50dJ/UXgCXWp5n14iFreYNlg41wURcak2oA3M+t2TVfD0xUUD6g=="], @@ -5922,6 +6125,8 @@ "@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "@types/better-sqlite3/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/cacheable-request/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], @@ -5938,6 +6143,8 @@ "@types/plist/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/prettier/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "@types/responselike/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/tedious/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], @@ -5978,6 +6185,8 @@ "atmn/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "atmn/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -5990,8 +6199,12 @@ "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -6028,6 +6241,8 @@ "dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -6054,6 +6269,8 @@ "execa/get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "executor/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -6072,6 +6289,10 @@ "h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -6096,6 +6317,12 @@ "ink-confirm-input/ink-text-input": ["ink-text-input@3.3.0", "", { "dependencies": { "chalk": "^3.0.0", "prop-types": "^15.5.10" }, "peerDependencies": { "ink": "^2.0.0", "react": "^16.5.2" } }, "sha512-gO4wrOf2ie3YuEARTIwGlw37lMjFn3Gk6CKIDrMlHb46WFMagZU7DplohjM24zynlqfnXA5UDEIfC2NBcvD8kg=="], + "jsdom/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + + "jsdom/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + + "json-schema-to-typescript/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -6120,6 +6347,8 @@ "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "node-gyp/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], @@ -6136,13 +6365,15 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "postcss/nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -6154,6 +6385,12 @@ "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -6184,9 +6421,9 @@ "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -6214,6 +6451,12 @@ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "svelte/aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], @@ -6236,6 +6479,10 @@ "unstorage/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "vitest/vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], @@ -6256,8 +6503,12 @@ "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "@astrojs/cloudflare/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + "@astrojs/react/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@cloudflare/vite-plugin/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], @@ -6400,8 +6651,264 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@executor-js/api/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/api/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/api/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/api/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/api/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/api/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/api/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/api/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/cli/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/cli/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/cli/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/cli/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/cli/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/cli/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/cloud/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/cloud/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/cloud/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/cloud/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/cloud/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/cloud/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/cloud/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/cloud/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/cloudflare/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/cloudflare/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/cloudflare/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/cloudflare/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/cloudflare/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/cloudflare/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/cloudflare/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/cloudflare/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/codemode-core/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/codemode-core/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/codemode-core/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/codemode-core/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/codemode-core/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/codemode-core/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/codemode-core/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/codemode-core/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/config/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/config/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/config/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/config/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/config/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/config/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/config/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/config/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/desktop/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/desktop/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/desktop/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/desktop/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/desktop/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/desktop/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/desktop/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/desktop/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "@executor-js/e2e/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@executor-js/e2e/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/e2e/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/e2e/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/e2e/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/e2e/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/e2e/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/e2e/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/e2e/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/execution/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/execution/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/execution/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/execution/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/execution/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/execution/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/execution/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/execution/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/fumadb/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/fumadb/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/fumadb/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/fumadb/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/fumadb/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/fumadb/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/fumadb/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/fumadb/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/host-cloudflare/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/host-cloudflare/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/host-cloudflare/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/host-cloudflare/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/host-cloudflare/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/host-cloudflare/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/host-cloudflare/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/host-cloudflare/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/host-mcp/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/host-mcp/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/host-mcp/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/host-mcp/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/host-mcp/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/host-mcp/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/host-mcp/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/host-mcp/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/host-selfhost/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/host-selfhost/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/host-selfhost/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/host-selfhost/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/host-selfhost/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/host-selfhost/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/host-selfhost/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/host-selfhost/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/integrations-registry/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/integrations-registry/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/integrations-registry/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/integrations-registry/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/integrations-registry/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/integrations-registry/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/integrations-registry/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/integrations-registry/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/ir/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/ir/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/ir/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/ir/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/ir/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/ir/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/ir/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/ir/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/local/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/local/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/local/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/local/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/local/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/local/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/local/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/local/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "@executor-js/mcporter/rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@executor-js/mcporter/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], @@ -6448,6 +6955,332 @@ "@executor-js/motel/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + "@executor-js/plugin-encrypted-secrets/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-encrypted-secrets/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-encrypted-secrets/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-encrypted-secrets/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-encrypted-secrets/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-encrypted-secrets/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-encrypted-secrets/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-encrypted-secrets/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-example/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-example/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-example/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-example/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-example/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-example/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-example/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-example/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-file-secrets/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-file-secrets/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-file-secrets/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-file-secrets/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-file-secrets/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-file-secrets/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-file-secrets/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-file-secrets/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-google/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-google/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-google/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-google/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-google/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-google/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-google/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-google/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-graphql/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-graphql/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-graphql/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-graphql/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-graphql/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-graphql/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-graphql/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-graphql/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-keychain/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-keychain/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-keychain/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-keychain/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-keychain/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-keychain/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-keychain/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-keychain/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-mcp/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-mcp/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-mcp/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-mcp/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-mcp/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-mcp/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-mcp/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-mcp/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-microsoft/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-microsoft/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-microsoft/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-microsoft/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-microsoft/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-microsoft/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-microsoft/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-microsoft/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-onepassword/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-onepassword/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-onepassword/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-onepassword/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-onepassword/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-onepassword/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-onepassword/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-onepassword/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-openapi/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-openapi/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-openapi/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-openapi/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-openapi/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-openapi/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-openapi/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-openapi/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/plugin-workos-vault/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/plugin-workos-vault/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/plugin-workos-vault/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/plugin-workos-vault/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/plugin-workos-vault/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/plugin-workos-vault/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/plugin-workos-vault/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/plugin-workos-vault/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/runtime-deno-subprocess/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/runtime-deno-subprocess/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/runtime-deno-subprocess/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/runtime-deno-subprocess/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/runtime-deno-subprocess/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/runtime-deno-subprocess/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/runtime-deno-subprocess/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/runtime-deno-subprocess/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/runtime-dynamic-worker/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/runtime-dynamic-worker/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/runtime-dynamic-worker/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/runtime-dynamic-worker/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/runtime-dynamic-worker/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/runtime-dynamic-worker/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/runtime-dynamic-worker/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/runtime-dynamic-worker/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/runtime-quickjs/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/runtime-quickjs/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/runtime-quickjs/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/runtime-quickjs/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/runtime-quickjs/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/runtime-quickjs/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/runtime-quickjs/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/runtime-quickjs/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/sdk/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/sdk/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/sdk/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/sdk/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/sdk/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/sdk/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/sdk/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/sdk/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/test-servers/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/test-servers/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/test-servers/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/test-servers/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/test-servers/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/test-servers/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/test-servers/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/test-servers/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/vite-plugin/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/vite-plugin/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/vite-plugin/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/vite-plugin/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/vite-plugin/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/vite-plugin/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/vite-plugin/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/vite-plugin/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@executor-js/web/oxlint/@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g=="], + + "@executor-js/web/oxlint/@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw=="], + + "@executor-js/web/oxlint/@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.71.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ=="], + + "@executor-js/web/oxlint/@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.71.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg=="], + + "@executor-js/web/oxlint/@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.71.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.71.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.71.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.71.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.71.0", "", { "os": "linux", "cpu": "none" }, "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.71.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g=="], + + "@executor-js/web/oxlint/@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.71.0", "", { "os": "linux", "cpu": "x64" }, "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA=="], + + "@executor-js/web/oxlint/@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.71.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw=="], + + "@executor-js/web/oxlint/@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.71.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA=="], + + "@executor-js/web/oxlint/@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.71.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw=="], + + "@executor-js/web/oxlint/@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="], + + "@executor-js/web/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@executor-js/web/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@executor-js/web/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@executor-js/web/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@executor-js/web/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@executor-js/web/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@executor-js/web/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@executor-js/web/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="], "@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], @@ -6514,6 +7347,8 @@ "@react-grab/cli/ora/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@sentry/electron/@sentry/browser/@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA=="], "@sentry/electron/@sentry/browser/@sentry-internal/feedback": ["@sentry-internal/feedback@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w=="], @@ -6544,8 +7379,12 @@ "@tanstack/router-generator/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "@tanstack/router-plugin/@tanstack/router-generator/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "@tanstack/start-plugin-core/@tanstack/router-generator/@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], + "@tanstack/start-plugin-core/@tanstack/router-generator/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "@types/cacheable-request/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], @@ -6594,6 +7433,8 @@ "astro/@clack/prompts/fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="], + "astro/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "builder-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -6604,6 +7445,8 @@ "bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -6756,12 +7599,34 @@ "electron-vite/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "electron-vite/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + + "executor/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "executor/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "executor/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "executor/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "executor/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "executor/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "executor/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "executor/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], @@ -6782,10 +7647,18 @@ "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "parse5-htmlparser2-tree-adapter/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], @@ -6818,6 +7691,8 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "temp-file/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "temp-file/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -6826,6 +7701,10 @@ "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "vitest/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + + "vitest/vite/rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -6890,6 +7769,10 @@ "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "@astrojs/cloudflare/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "@astrojs/react/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260415.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg=="], "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260415.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ=="], @@ -7032,8 +7915,12 @@ "astro/@clack/prompts/fast-string-width/fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], + "astro/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "electron-vite/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7060,6 +7947,44 @@ "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "vitest/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], + + "vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], + + "vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], + + "vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], + + "vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], + + "vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], + + "vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], + + "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], + + "vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], + + "vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], + + "vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + + "@executor-js/mcporter/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -7070,6 +7995,12 @@ "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], } } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..1c8597fbf --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,85 @@ +# Executor rewrite architecture + +## Product boundary + +The rewrite targets a single-user, local and self-hosted Executor. Linux and +macOS native binaries plus Docker are first-class release targets. The current +cloud and desktop products remain legacy surfaces and do not constrain the new +architecture. + +The production artifact is one Rust binary. It owns the control API, gateway +API, embedded SQLite state, upstream connections, sandboxed TypeScript runtime, +approval coordination, and static Svelte application. The production runtime +does not require Node.js. Packaging builds the `web/` application first and +embeds its static output at the `src/web_assets.rs` boundary. Cargo checks and +tests do not depend on generated web assets. + +Version one supports these upstream source types: + +- MCP servers over local stdio and remote Streamable HTTP +- OpenAPI-described HTTP APIs +- GraphQL endpoints + +It supports API key, bearer token, basic authentication, and OAuth credential +flows. Each source has one active credential profile. Integrations and existing +data migration are intentionally out of scope. + +## Control and gateway planes + +The two authentication planes are deliberately disjoint: + +- Control APIs accept only an administrator session cookie. Unsafe requests + also require an origin match and CSRF token. +- Gateway APIs accept only dashboard-generated API tokens. + +API tokens initially share one global enabled-tool set. Interactive approval +rules are evaluated separately and remain available for sensitive calls. An API +token can never authorize an administrator route. + +First boot prints a one-time, high-entropy setup token in a fragment URL. Only a +keyed digest is stored. The atomic setup claim creates exactly one administrator +whose password is hashed with Argon2id. Administrator sessions and API tokens +are opaque, high-entropy values stored only as keyed digests. API token secrets +are shown once. + +Reverse-proxy client addresses are disabled by default. `--trusted-proxy ` +may be repeated, or `EXECUTOR_TRUSTED_PROXIES` may contain a comma-separated +list. Executor honors `X-Forwarded-For` only when the direct peer is trusted, +then walks the chain from right to left through configured trusted proxies. +Malformed, missing, or all-trusted chains are rejected before password work so +they cannot create a proxy-wide login-rate-limit bucket. Every intermediary +proxy must be listed and must append or replace `X-Forwarded-For` correctly. + +## Storage and key management + +SQLite is opened with foreign keys, WAL mode, a five-second busy timeout, and +`synchronous=FULL`. FULL is the initial durability choice because this local +control plane stores credential and approval state, while the expected write +volume is modest. This can be revisited with benchmarks. Network work must +never occur inside a database transaction. + +Migrations are append-only SQL files embedded into the binary. The instance +master key comes from `EXECUTOR_MASTER_KEY_FILE`, or is atomically created as a +0600 file inside a 0700 data directory. An initialized database without its key +fails closed. A boot sentinel detects a wrong key or modified ciphertext. +Protected values use XChaCha20-Poly1305. HKDF derives purpose-specific subkeys, +and associated data binds ciphertext to its purpose and record identity. + +## Delivery slices + +1. Foundation: one Cargo package and binary, SQLite migrations, master-key + lifecycle, setup, administrator sessions, CSRF and origin checks, API token + management, a gateway authentication proof, and the Svelte SPA shell. +2. Sources: MCP stdio and Streamable HTTP, OpenAPI, GraphQL, credential profiles, + OAuth callbacks, connectivity testing, and source lifecycle UI. +3. Tool catalog: normalized discovery, search and describe, global enable state, + bulk and filtered enable workflows, and request-aware tool testing. +4. Runtime and approvals: sandboxed TypeScript execution, concurrent tool calls, + interactive approvals, resume semantics, and CLI parity. +5. Operations: request logs, redaction, retention, embedded web assets, Docker, + Linux and macOS packaging, upgrade safety, e2e recordings, and migration of + the old TypeScript products into `legacy/` after parity is proven. + +The current TypeScript implementation stays in place until the replacement has +parity. Moving it early would obscure behavior that still serves as the +reference contract. diff --git a/migrations/20260623000000_initial.sql b/migrations/20260623000000_initial.sql new file mode 100644 index 000000000..0684174f4 --- /dev/null +++ b/migrations/20260623000000_initial.sql @@ -0,0 +1,45 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE instance_metadata ( + key TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL +); + +CREATE TABLE setup_state ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + token_digest BLOB NOT NULL, + created_at INTEGER NOT NULL, + used_at INTEGER +); + +CREATE TABLE admins ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE admin_sessions ( + session_digest BLOB PRIMARY KEY NOT NULL, + admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + csrf_digest BLOB NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX admin_sessions_expiry_idx ON admin_sessions(expires_at); + +CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + token_digest BLOB NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + token_suffix TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked_at INTEGER +); + +CREATE INDEX api_tokens_active_digest_idx + ON api_tokens(token_digest) + WHERE revoked_at IS NULL; diff --git a/package.json b/package.json index 8f40bea46..b86f52d88 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "packages/app", "apps/*", "examples/*", - "e2e" + "e2e", + "web" ], "type": "module", "scripts": { diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 000000000..8c4f44d3f --- /dev/null +++ b/src/api.rs @@ -0,0 +1,1090 @@ +use std::{ + collections::{HashMap, VecDeque}, + net::{IpAddr, SocketAddr}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use axum::{ + Json, Router, + extract::{ConnectInfo, DefaultBodyLimit, Extension, Path, State, rejection::JsonRejection}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, +}; +use ipnet::IpNet; +use serde::{Deserialize, Serialize}; +use subtle::ConstantTimeEq; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use uuid::Uuid; + +use crate::{ + AppConfig, + crypto::{generate_secret, hash_password, verify_password}, + database::{Database, SETUP_TOKEN_TTL_SECONDS}, + unix_timestamp, +}; + +const SESSION_COOKIE: &str = "executor_session"; +const CSRF_COOKIE: &str = "executor_csrf"; +const CSRF_HEADER: &str = "x-executor-csrf"; +const MAX_API_BODY_BYTES: usize = 16 * 1024; +const PASSWORD_HASH_CONCURRENCY: usize = 2; +const MAX_USERNAME_CHARACTERS: usize = 64; +const MAX_USERNAME_BYTES: usize = 256; +const MIN_PASSWORD_CHARACTERS: usize = 12; +const MAX_PASSWORD_BYTES: usize = 1024; +const MAX_SETUP_TOKEN_BYTES: usize = 128; +const MAX_TOKEN_NAME_CHARACTERS: usize = 80; +const MAX_TOKEN_NAME_BYTES: usize = 320; +const LOGIN_ATTEMPT_LIMIT: usize = 5; +const LOGIN_ATTEMPT_WINDOW: Duration = Duration::from_secs(60); +const MAX_LOGIN_RATE_LIMIT_CLIENTS: usize = 4096; +const MAX_FORWARDED_FOR_HOPS: usize = 64; +const MAX_FORWARDED_FOR_BYTES: usize = 4 * 1024; +const X_FORWARDED_FOR: &str = "x-forwarded-for"; + +#[derive(Clone)] +struct AppState { + database: Database, + origin: Arc, + session_ttl_seconds: i64, + secure_cookies: bool, + password_hash_slots: Arc, + dummy_password_hash: Arc, + login_rate_limiter: Arc, + trusted_proxies: Arc<[IpNet]>, +} + +#[derive(Clone)] +struct RequestId(String); + +#[derive(Clone, Hash, Eq, PartialEq)] +enum LoginClientKey { + PeerIp(IpAddr), + InProcess, + InvalidForwardedChain(&'static str), +} + +struct LoginRateLimiter { + attempts: Mutex>>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorEnvelope { + error: ErrorDetail, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorDetail { + code: &'static str, + message: String, + request_id: String, +} + +struct ApiError { + status: StatusCode, + code: &'static str, + message: String, + request_id: String, + retry_after_seconds: Option, +} + +impl ApiError { + fn new( + request_id: &RequestId, + status: StatusCode, + code: &'static str, + message: impl Into, + ) -> Self { + Self { + status, + code, + message: message.into(), + request_id: request_id.0.clone(), + retry_after_seconds: None, + } + } + + fn unauthorized(request_id: &RequestId, message: impl Into) -> Self { + Self::new( + request_id, + StatusCode::UNAUTHORIZED, + "unauthorized", + message, + ) + } + + fn internal(request_id: &RequestId) -> Self { + Self::new( + request_id, + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "The request could not be completed.", + ) + } + + fn internal_logged(request_id: &RequestId, error: impl std::fmt::Display) -> Self { + tracing::error!(request_id = %request_id.0, error = %error, "control API request failed"); + Self::internal(request_id) + } + + fn password_work_saturated(request_id: &RequestId) -> Self { + Self::new( + request_id, + StatusCode::TOO_MANY_REQUESTS, + "password_work_saturated", + "Password verification is busy. Try again shortly.", + ) + .with_retry_after(1) + } + + fn login_rate_limited(request_id: &RequestId, retry_after_seconds: u64) -> Self { + Self::new( + request_id, + StatusCode::TOO_MANY_REQUESTS, + "login_rate_limited", + "Too many login attempts. Try again later.", + ) + .with_retry_after(retry_after_seconds) + } + + fn with_retry_after(mut self, retry_after_seconds: u64) -> Self { + self.retry_after_seconds = Some(retry_after_seconds); + self + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let retry_after_seconds = self.retry_after_seconds; + let mut response = ( + self.status, + Json(ErrorEnvelope { + error: ErrorDetail { + code: self.code, + message: self.message, + request_id: self.request_id, + }, + }), + ) + .into_response(); + if let Some(retry_after_seconds) = retry_after_seconds { + response.headers_mut().insert( + header::RETRY_AFTER, + HeaderValue::from_str(&retry_after_seconds.to_string()) + .expect("retry delays are valid header values"), + ); + } + response + } +} + +impl LoginRateLimiter { + fn new() -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + } + } + + fn check(&self, client: &LoginClientKey) -> Result<(), u64> { + let now = Instant::now(); + let cutoff = now.checked_sub(LOGIN_ATTEMPT_WINDOW).unwrap_or(now); + let mut attempts_by_client = self + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + attempts_by_client.retain(|_, attempts| { + while attempts.front().is_some_and(|attempt| *attempt <= cutoff) { + attempts.pop_front(); + } + !attempts.is_empty() + }); + + if !attempts_by_client.contains_key(client) + && attempts_by_client.len() >= MAX_LOGIN_RATE_LIMIT_CLIENTS + { + let oldest_client = attempts_by_client + .iter() + .filter_map(|(client, attempts)| attempts.front().map(|attempt| (client, attempt))) + .min_by_key(|(_, attempt)| *attempt) + .map(|(client, _)| client.clone()); + if let Some(oldest_client) = oldest_client { + attempts_by_client.remove(&oldest_client); + } + } + + let attempts = attempts_by_client.entry(client.clone()).or_default(); + if attempts.len() >= LOGIN_ATTEMPT_LIMIT { + let oldest = attempts.front().copied().unwrap_or(now); + let remaining = LOGIN_ATTEMPT_WINDOW.saturating_sub(now.duration_since(oldest)); + return Err(remaining.as_secs().max(1)); + } + attempts.push_back(now); + Ok(()) + } + + fn clear(&self, client: &LoginClientKey) { + self.attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(client); + } +} + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BootstrapResponse { + setup_required: bool, + authenticated: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SetupRequest { + setup_token: String, + username: String, + password: String, +} + +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionResponse { + username: String, + csrf_token: Option, +} + +#[derive(Deserialize)] +struct CreateTokenRequest { + name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreatedTokenResponse { + id: String, + name: String, + token: String, + created_at: i64, +} + +#[derive(Serialize, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +struct TokenMetadata { + id: String, + name: String, + masked_token: String, + created_at: i64, + last_used_at: Option, + revoked_at: Option, +} + +#[derive(Serialize)] +struct TokenListResponse { + tokens: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GatewayIdentityResponse { + token_id: String, + token_name: String, +} + +struct AdminSession { + username: String, + session_digest: Vec, + csrf_digest: Vec, +} + +pub(crate) fn router( + database: Database, + config: &AppConfig, + dummy_password_hash: String, +) -> Router { + let state = AppState { + database, + origin: Arc::from(config.public_origin()), + session_ttl_seconds: config.session_ttl_seconds, + secure_cookies: config.origin.scheme() == "https", + password_hash_slots: Arc::new(Semaphore::new(PASSWORD_HASH_CONCURRENCY)), + dummy_password_hash: Arc::from(dummy_password_hash), + login_rate_limiter: Arc::new(LoginRateLimiter::new()), + trusted_proxies: config.trusted_proxies.clone(), + }; + let middleware_state = state.clone(); + + Router::new() + .route("/healthz", get(health)) + .route("/api/v1/bootstrap", get(bootstrap)) + .route("/api/v1/setup", post(setup)) + .route("/api/v1/session", post(login).get(session).delete(logout)) + .route("/api/v1/tokens", get(list_tokens).post(create_token)) + .route("/api/v1/tokens/{id}", delete(revoke_token)) + .route("/api/v1/gateway/whoami", get(gateway_whoami)) + .fallback(not_found) + .with_state(state) + .layer(DefaultBodyLimit::max(MAX_API_BODY_BYTES)) + .layer(middleware::from_fn_with_state( + middleware_state, + request_id_middleware, + )) +} + +async fn request_id_middleware( + State(state): State, + mut request: axum::extract::Request, + next: Next, +) -> Response { + let request_id = RequestId(Uuid::new_v4().to_string()); + let peer_ip = request + .extensions() + .get::>() + .map(|ConnectInfo(peer)| canonical_ip(peer.ip())); + let login_client = login_client_key(peer_ip, request.headers(), &state.trusted_proxies); + request.extensions_mut().insert(request_id.clone()); + request.extensions_mut().insert(login_client); + let mut response = next.run(request).await; + response.headers_mut().insert( + "x-request-id", + HeaderValue::from_str(&request_id.0).expect("UUID request IDs are valid headers"), + ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +fn login_client_key( + peer_ip: Option, + headers: &HeaderMap, + trusted_proxies: &[IpNet], +) -> LoginClientKey { + let Some(peer_ip) = peer_ip else { + return LoginClientKey::InProcess; + }; + if !is_trusted_proxy(peer_ip, trusted_proxies) { + return LoginClientKey::PeerIp(peer_ip); + } + + let forwarded_for = match forwarded_for_chain(headers) { + Ok(forwarded_for) => forwarded_for, + Err(reason) => return LoginClientKey::InvalidForwardedChain(reason), + }; + let mut client_ip = peer_ip; + for forwarded_ip in forwarded_for.into_iter().rev() { + if !is_trusted_proxy(client_ip, trusted_proxies) { + return LoginClientKey::PeerIp(client_ip); + } + let Ok(forwarded_ip) = forwarded_ip.parse() else { + return LoginClientKey::InvalidForwardedChain("malformed X-Forwarded-For address"); + }; + client_ip = canonical_ip(forwarded_ip); + } + if is_trusted_proxy(client_ip, trusted_proxies) { + LoginClientKey::InvalidForwardedChain("no untrusted client address in X-Forwarded-For") + } else { + LoginClientKey::PeerIp(client_ip) + } +} + +fn forwarded_for_chain(headers: &HeaderMap) -> Result, &'static str> { + let mut chain = Vec::new(); + let mut total_bytes = 0_usize; + for value in headers.get_all(X_FORWARDED_FOR).iter() { + let value = value + .to_str() + .map_err(|_| "X-Forwarded-For is not valid text")?; + total_bytes = total_bytes + .checked_add(value.len()) + .filter(|length| *length <= MAX_FORWARDED_FOR_BYTES) + .ok_or("X-Forwarded-For is too large")?; + for address in value.split(',') { + if chain.len() >= MAX_FORWARDED_FOR_HOPS { + return Err("X-Forwarded-For has too many hops"); + } + chain.push(address.trim()); + } + } + if chain.is_empty() { + Err("X-Forwarded-For is missing") + } else { + Ok(chain) + } +} + +fn is_trusted_proxy(address: IpAddr, trusted_proxies: &[IpNet]) -> bool { + trusted_proxies + .iter() + .any(|network| network.contains(&address)) +} + +fn canonical_ip(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + } +} + +async fn health() -> Json { + Json(HealthResponse { status: "ok" }) +} + +async fn bootstrap( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let setup_required = + sqlx::query_scalar::<_, i64>("SELECT NOT EXISTS(SELECT 1 FROM admins WHERE id = 1)") + .fetch_one(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + != 0; + let authenticated = optional_admin_session(&state, &headers) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + .is_some(); + Ok(Json(BootstrapResponse { + setup_required, + authenticated, + })) +} + +async fn setup( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result { + require_origin(&request_id, &state, &headers)?; + let Json(payload) = parse_json(&request_id, payload)?; + let username = validate_username(&request_id, &payload.username)?; + validate_setup_password(&request_id, &payload.password)?; + if payload.setup_token.is_empty() || payload.setup_token.len() > MAX_SETUP_TOKEN_BYTES { + return Err(ApiError::unauthorized( + &request_id, + "The setup token is invalid or has expired.", + )); + } + + let digest = state + .database + .keyring + .digest("setup-token", payload.setup_token.as_bytes()); + let now = unix_timestamp(); + let setup_status = sqlx::query_scalar::<_, i64>( + "SELECT CASE \ + WHEN EXISTS(SELECT 1 FROM admins WHERE id = 1) THEN 2 \ + WHEN EXISTS(SELECT 1 FROM setup_state WHERE id = 1 AND used_at IS NULL \ + AND token_digest = ? AND created_at >= ?) THEN 1 \ + ELSE 0 END", + ) + .bind(digest.to_vec()) + .bind(now - SETUP_TOKEN_TTL_SECONDS) + .fetch_one(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + match setup_status { + 2 => { + return Err(ApiError::new( + &request_id, + StatusCode::CONFLICT, + "setup_complete", + "This Executor instance is already configured.", + )); + } + 1 => {} + _ => { + return Err(ApiError::unauthorized( + &request_id, + "The setup token is invalid or has expired.", + )); + } + } + + let permit = acquire_password_slot(&request_id, &state)?; + let password = payload.password; + let password_hash = tokio::task::spawn_blocking(move || { + let _permit = permit; + hash_password(&password) + }) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + let claim_time = unix_timestamp(); + let mut transaction = state + .database + .pool + .begin() + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + let inserted = sqlx::query( + "INSERT OR IGNORE INTO admins (id, username, password_hash, created_at) \ + SELECT 1, ?, ?, ? FROM setup_state \ + WHERE id = 1 AND used_at IS NULL AND token_digest = ? AND created_at >= ?", + ) + .bind(&username) + .bind(password_hash) + .bind(claim_time) + .bind(digest.to_vec()) + .bind(claim_time - SETUP_TOKEN_TTL_SECONDS) + .execute(&mut *transaction) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + .rows_affected(); + + if inserted == 0 { + let setup_complete = + sqlx::query_scalar::<_, i64>("SELECT EXISTS(SELECT 1 FROM admins WHERE id = 1)") + .fetch_one(&mut *transaction) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + != 0; + transaction + .rollback() + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + return if setup_complete { + Err(ApiError::new( + &request_id, + StatusCode::CONFLICT, + "setup_complete", + "This Executor instance is already configured.", + )) + } else { + Err(ApiError::unauthorized( + &request_id, + "The setup token is invalid or has expired.", + )) + }; + } + + sqlx::query("UPDATE setup_state SET used_at = ? WHERE id = 1") + .bind(claim_time) + .execute(&mut *transaction) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + transaction + .commit() + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + + Ok(( + StatusCode::CREATED, + Json(SessionResponse { + username, + csrf_token: None, + }), + )) +} + +async fn login( + Extension(request_id): Extension, + Extension(login_client): Extension, + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result { + require_origin(&request_id, &state, &headers)?; + let Json(payload) = parse_json(&request_id, payload)?; + let requested_username = validate_username(&request_id, &payload.username)?; + validate_login_password(&request_id, &payload.password)?; + if let LoginClientKey::InvalidForwardedChain(reason) = &login_client { + tracing::warn!(request_id = %request_id.0, reason, "trusted proxy chain rejected"); + return Err(ApiError::unauthorized( + &request_id, + "The request could not be authenticated.", + )); + } + state + .login_rate_limiter + .check(&login_client) + .map_err(|retry_after| ApiError::login_rate_limited(&request_id, retry_after))?; + let admin = sqlx::query_as::<_, (i64, String, String)>( + "SELECT id, username, password_hash FROM admins WHERE username = ?", + ) + .bind(&requested_username) + .fetch_optional(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + let (admin_id, username, password_hash, admin_exists) = match admin { + Some((admin_id, username, password_hash)) => (admin_id, username, password_hash, true), + None => ( + 0, + requested_username, + state.dummy_password_hash.to_string(), + false, + ), + }; + let permit = acquire_password_slot(&request_id, &state)?; + let password = payload.password; + let verified = tokio::task::spawn_blocking(move || { + let _permit = permit; + verify_password(&password, &password_hash) + }) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + if !admin_exists || !verified { + return Err(ApiError::unauthorized( + &request_id, + "The username or password is incorrect.", + )); + } + + let session_token = generate_secret("ses_"); + let csrf_token = generate_secret("csrf_"); + let session_digest = state + .database + .keyring + .digest("admin-session", session_token.as_bytes()); + let csrf_digest = state + .database + .keyring + .digest("csrf-token", csrf_token.as_bytes()); + let now = unix_timestamp(); + let expires_at = now + state.session_ttl_seconds; + sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= ?") + .bind(now) + .execute(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + sqlx::query( + "INSERT INTO admin_sessions \ + (session_digest, admin_id, csrf_digest, created_at, expires_at) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(session_digest.to_vec()) + .bind(admin_id) + .bind(csrf_digest.to_vec()) + .bind(now) + .bind(expires_at) + .execute(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + state.login_rate_limiter.clear(&login_client); + + let mut response = Json(SessionResponse { + username, + csrf_token: Some(csrf_token.clone()), + }) + .into_response(); + append_set_cookie( + response.headers_mut(), + session_cookie(&state, &session_token), + ); + append_set_cookie(response.headers_mut(), csrf_cookie(&state, &csrf_token)); + Ok(response) +} + +async fn session( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let admin = require_admin(&request_id, &state, &headers).await?; + Ok(Json(SessionResponse { + username: admin.username, + csrf_token: None, + })) +} + +async fn logout( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, +) -> Result { + let admin = require_admin_mutation(&request_id, &state, &headers).await?; + sqlx::query("DELETE FROM admin_sessions WHERE session_digest = ?") + .bind(admin.session_digest) + .execute(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + + let mut response = StatusCode::NO_CONTENT.into_response(); + append_set_cookie(response.headers_mut(), clear_cookie(SESSION_COOKIE)); + append_set_cookie(response.headers_mut(), clear_cookie(CSRF_COOKIE)); + Ok(response) +} + +async fn create_token( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result { + require_admin_mutation(&request_id, &state, &headers).await?; + let Json(payload) = parse_json(&request_id, payload)?; + let name = payload.name.trim(); + if name.is_empty() + || name.chars().count() > MAX_TOKEN_NAME_CHARACTERS + || name.len() > MAX_TOKEN_NAME_BYTES + { + return Err(ApiError::new( + &request_id, + StatusCode::BAD_REQUEST, + "invalid_token_name", + "Token names must contain between 1 and 80 characters.", + )); + } + + let token = generate_secret("exr_"); + let digest = state.database.keyring.digest("api-token", token.as_bytes()); + let id = Uuid::new_v4().to_string(); + let prefix = token.chars().take(8).collect::(); + let suffix = token + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + let created_at = unix_timestamp(); + sqlx::query( + "INSERT INTO api_tokens \ + (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(name) + .bind(digest.to_vec()) + .bind(prefix) + .bind(suffix) + .bind(created_at) + .execute(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + + Ok(( + StatusCode::CREATED, + Json(CreatedTokenResponse { + id, + name: name.to_owned(), + token, + created_at, + }), + )) +} + +async fn list_tokens( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let tokens = sqlx::query_as::<_, TokenMetadata>( + "SELECT id, name, token_prefix || '...' || token_suffix AS masked_token, \ + created_at, last_used_at, revoked_at \ + FROM api_tokens ORDER BY created_at DESC, id DESC", + ) + .fetch_all(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + Ok(Json(TokenListResponse { tokens })) +} + +async fn revoke_token( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + Path(token_id): Path, +) -> Result { + require_admin_mutation(&request_id, &state, &headers).await?; + let changed = + sqlx::query("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") + .bind(unix_timestamp()) + .bind(token_id) + .execute(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + .rows_affected(); + if changed == 0 { + return Err(ApiError::new( + &request_id, + StatusCode::NOT_FOUND, + "token_not_found", + "The API token was not found or was already revoked.", + )); + } + Ok(StatusCode::NO_CONTENT) +} + +async fn gateway_whoami( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let token = bearer_token(&headers).ok_or_else(|| { + ApiError::unauthorized(&request_id, "A valid Executor API token is required.") + })?; + let digest = state.database.keyring.digest("api-token", token.as_bytes()); + let identity = sqlx::query_as::<_, (String, String)>( + "UPDATE api_tokens SET last_used_at = ? \ + WHERE token_digest = ? AND revoked_at IS NULL RETURNING id, name", + ) + .bind(unix_timestamp()) + .bind(digest.to_vec()) + .fetch_optional(&state.database.pool) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + .ok_or_else(|| { + ApiError::unauthorized(&request_id, "A valid Executor API token is required.") + })?; + Ok(Json(GatewayIdentityResponse { + token_id: identity.0, + token_name: identity.1, + })) +} + +async fn not_found(Extension(request_id): Extension) -> ApiError { + ApiError::new( + &request_id, + StatusCode::NOT_FOUND, + "not_found", + "The requested resource does not exist.", + ) +} + +fn parse_json( + request_id: &RequestId, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + payload.map_err(|rejection| { + if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE { + ApiError::new( + request_id, + StatusCode::PAYLOAD_TOO_LARGE, + "payload_too_large", + "The request body exceeds the allowed size.", + ) + } else { + ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_json", + "The request body must be valid JSON with the expected fields.", + ) + } + }) +} + +fn validate_username(request_id: &RequestId, username: &str) -> Result { + let username = username.trim(); + let username_length = username.chars().count(); + if !(1..=MAX_USERNAME_CHARACTERS).contains(&username_length) + || username.len() > MAX_USERNAME_BYTES + { + return Err(ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_username", + "Usernames must contain between 1 and 64 characters.", + )); + } + Ok(username.to_owned()) +} + +fn validate_setup_password(request_id: &RequestId, password: &str) -> Result<(), ApiError> { + if password.chars().count() < MIN_PASSWORD_CHARACTERS || password.len() > MAX_PASSWORD_BYTES { + return Err(ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_password", + "Passwords must contain at least 12 characters.", + )); + } + Ok(()) +} + +fn validate_login_password(request_id: &RequestId, password: &str) -> Result<(), ApiError> { + if password.len() > MAX_PASSWORD_BYTES { + return Err(ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_password", + "The password exceeds the allowed size.", + )); + } + Ok(()) +} + +fn acquire_password_slot( + request_id: &RequestId, + state: &AppState, +) -> Result { + state + .password_hash_slots + .clone() + .try_acquire_owned() + .map_err(|_| ApiError::password_work_saturated(request_id)) +} + +fn require_origin( + request_id: &RequestId, + state: &AppState, + headers: &HeaderMap, +) -> Result<(), ApiError> { + let origin_matches = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .is_some_and(|origin| origin == state.origin.as_ref()); + if origin_matches { + Ok(()) + } else { + Err(ApiError::new( + request_id, + StatusCode::FORBIDDEN, + "invalid_origin", + "The request Origin does not match this Executor instance.", + )) + } +} + +async fn require_admin( + request_id: &RequestId, + state: &AppState, + headers: &HeaderMap, +) -> Result { + optional_admin_session(state, headers) + .await + .map_err(|error| ApiError::internal_logged(request_id, error))? + .ok_or_else(|| ApiError::unauthorized(request_id, "An administrator session is required.")) +} + +async fn optional_admin_session( + state: &AppState, + headers: &HeaderMap, +) -> Result, sqlx::Error> { + let Some(token) = cookie_value(headers, SESSION_COOKIE) else { + return Ok(None); + }; + let digest = state + .database + .keyring + .digest("admin-session", token.as_bytes()); + sqlx::query_as::<_, (String, Vec, Vec)>( + "SELECT admins.username, admin_sessions.session_digest, admin_sessions.csrf_digest \ + FROM admin_sessions JOIN admins ON admins.id = admin_sessions.admin_id \ + WHERE admin_sessions.session_digest = ? AND admin_sessions.expires_at > ?", + ) + .bind(digest.to_vec()) + .bind(unix_timestamp()) + .fetch_optional(&state.database.pool) + .await + .map(|row| { + row.map(|(username, session_digest, csrf_digest)| AdminSession { + username, + session_digest, + csrf_digest, + }) + }) +} + +async fn require_admin_mutation( + request_id: &RequestId, + state: &AppState, + headers: &HeaderMap, +) -> Result { + require_origin(request_id, state, headers)?; + let admin = require_admin(request_id, state, headers).await?; + let csrf_header = headers + .get(CSRF_HEADER) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + ApiError::new( + request_id, + StatusCode::FORBIDDEN, + "invalid_csrf", + "A valid CSRF token is required.", + ) + })?; + let csrf_cookie = cookie_value(headers, CSRF_COOKIE).ok_or_else(|| { + ApiError::new( + request_id, + StatusCode::FORBIDDEN, + "invalid_csrf", + "A valid CSRF token is required.", + ) + })?; + let same_token: bool = csrf_header.as_bytes().ct_eq(csrf_cookie.as_bytes()).into(); + let valid_digest = state.database.keyring.digest_matches( + "csrf-token", + csrf_header.as_bytes(), + &admin.csrf_digest, + ); + if !same_token || !valid_digest { + return Err(ApiError::new( + request_id, + StatusCode::FORBIDDEN, + "invalid_csrf", + "A valid CSRF token is required.", + )); + } + Ok(admin) +} + +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|cookie| cookie.trim().split_once('=')) + .find_map(|(cookie_name, value)| (cookie_name == name).then(|| value.to_owned())) +} + +fn bearer_token(headers: &HeaderMap) -> Option<&str> { + let value = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok())?; + let mut parts = value.split_ascii_whitespace(); + let scheme = parts.next()?; + let token = parts.next()?; + (scheme.eq_ignore_ascii_case("bearer") && parts.next().is_none()).then_some(token) +} + +fn append_set_cookie(headers: &mut HeaderMap, cookie: String) { + headers.append( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).expect("generated cookies contain only safe characters"), + ); +} + +fn session_cookie(state: &AppState, token: &str) -> String { + let secure = if state.secure_cookies { "; Secure" } else { "" }; + format!( + "{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}{secure}", + state.session_ttl_seconds + ) +} + +fn csrf_cookie(state: &AppState, token: &str) -> String { + let secure = if state.secure_cookies { "; Secure" } else { "" }; + format!( + "{CSRF_COOKIE}={token}; Path=/; SameSite=Lax; Max-Age={}{secure}", + state.session_ttl_seconds + ) +} + +fn clear_cookie(name: &str) -> String { + format!("{name}=; Path=/; SameSite=Lax; Max-Age=0") +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 000000000..a8f3e20ea --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,429 @@ +use std::{ + fs::{self, OpenOptions}, + io::{ErrorKind, Write}, + path::{Path, PathBuf}, +}; + +use argon2::{ + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, + password_hash::{SaltString, rand_core::OsRng}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chacha20poly1305::{ + KeyInit, XChaCha20Poly1305, XNonce, + aead::{Aead, Payload}, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use rand::{RngCore, rngs::OsRng as SystemOsRng}; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use thiserror::Error; +use uuid::Uuid; + +const MASTER_KEY_LENGTH: usize = 32; +const NONCE_LENGTH: usize = 24; +const ENVELOPE_VERSION: u8 = 1; + +#[derive(Debug, Eq, PartialEq)] +enum KeyPublishOutcome { + Published, + Existing, +} + +#[derive(Debug, Error)] +pub enum CryptoError { + #[error("could not create the Executor data directory: {0}")] + CreateDataDirectory(#[source] std::io::Error), + #[error("the initialized database has no master key file at {0}")] + MissingMasterKey(PathBuf), + #[error("could not read the master key file at {path}: {source}")] + ReadMasterKey { + path: PathBuf, + source: std::io::Error, + }, + #[error("the master key file at {path} must contain exactly 32 bytes, found {length}")] + InvalidMasterKeyLength { path: PathBuf, length: usize }, + #[error("could not create a master key file at {path}: {source}")] + CreateMasterKey { + path: PathBuf, + source: std::io::Error, + }, + #[error("could not derive an instance subkey")] + KeyDerivation, + #[error("could not encrypt protected data")] + Encryption, + #[error("could not decrypt protected data")] + Decryption, + #[error("unsupported protected-data envelope version {0}")] + UnsupportedEnvelopeVersion(u8), + #[error("could not hash the password")] + PasswordHash, +} + +#[derive(Clone)] +pub struct Keyring { + encryption_key: [u8; 32], + digest_key: [u8; 32], +} + +impl Keyring { + pub fn from_master_key(master_key: [u8; MASTER_KEY_LENGTH]) -> Result { + let hkdf = Hkdf::::new(Some(b"executor-instance-v1"), &master_key); + let mut encryption_key = [0_u8; 32]; + let mut digest_key = [0_u8; 32]; + hkdf.expand(b"xchacha20poly1305-at-rest", &mut encryption_key) + .map_err(|_| CryptoError::KeyDerivation)?; + hkdf.expand(b"authentication-digests", &mut digest_key) + .map_err(|_| CryptoError::KeyDerivation)?; + + Ok(Self { + encryption_key, + digest_key, + }) + } + + pub fn random() -> Result { + let mut master_key = [0_u8; MASTER_KEY_LENGTH]; + SystemOsRng.fill_bytes(&mut master_key); + Self::from_master_key(master_key) + } + + pub fn encrypt( + &self, + purpose: &str, + record_id: &str, + plaintext: &[u8], + ) -> Result, CryptoError> { + let cipher = XChaCha20Poly1305::new((&self.encryption_key).into()); + let mut nonce_bytes = [0_u8; NONCE_LENGTH]; + SystemOsRng.fill_bytes(&mut nonce_bytes); + let aad = aad(purpose, record_id); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce_bytes), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| CryptoError::Encryption)?; + + let mut sealed = Vec::with_capacity(1 + NONCE_LENGTH + ciphertext.len()); + sealed.push(ENVELOPE_VERSION); + sealed.extend_from_slice(&nonce_bytes); + sealed.extend_from_slice(&ciphertext); + Ok(sealed) + } + + pub fn decrypt( + &self, + purpose: &str, + record_id: &str, + sealed: &[u8], + ) -> Result, CryptoError> { + let Some((&version, envelope)) = sealed.split_first() else { + return Err(CryptoError::Decryption); + }; + if version != ENVELOPE_VERSION { + return Err(CryptoError::UnsupportedEnvelopeVersion(version)); + } + if envelope.len() <= NONCE_LENGTH { + return Err(CryptoError::Decryption); + } + + let (nonce, ciphertext) = envelope.split_at(NONCE_LENGTH); + let aad = aad(purpose, record_id); + XChaCha20Poly1305::new((&self.encryption_key).into()) + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: &aad, + }, + ) + .map_err(|_| CryptoError::Decryption) + } + + pub(crate) fn digest(&self, purpose: &str, secret: &[u8]) -> [u8; 32] { + let mut mac = as Mac>::new_from_slice(&self.digest_key) + .expect("HMAC accepts a 32-byte key"); + mac.update(b"executor:v1:"); + mac.update(purpose.as_bytes()); + mac.update(b":"); + mac.update(secret); + mac.finalize().into_bytes().into() + } + + pub(crate) fn digest_matches(&self, purpose: &str, secret: &[u8], expected: &[u8]) -> bool { + self.digest(purpose, secret) + .as_slice() + .ct_eq(expected) + .into() + } +} + +pub(crate) fn generate_secret(prefix: &str) -> String { + let mut bytes = [0_u8; 32]; + SystemOsRng.fill_bytes(&mut bytes); + format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes)) +} + +pub(crate) fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|_| CryptoError::PasswordHash) +} + +pub(crate) fn verify_password(password: &str, encoded_hash: &str) -> bool { + let Ok(hash) = PasswordHash::new(encoded_hash) else { + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok() +} + +pub(crate) fn load_or_create_master_key( + data_dir: &Path, + database_existed: bool, + configured_path: Option<&Path>, +) -> Result<[u8; MASTER_KEY_LENGTH], CryptoError> { + load_or_create_master_key_with_sync( + data_dir, + database_existed, + configured_path, + &sync_directory, + ) +} + +fn load_or_create_master_key_with_sync( + data_dir: &Path, + database_existed: bool, + configured_path: Option<&Path>, + sync_parent: &impl Fn(&Path) -> Result<(), std::io::Error>, +) -> Result<[u8; MASTER_KEY_LENGTH], CryptoError> { + fs::create_dir_all(data_dir).map_err(CryptoError::CreateDataDirectory)?; + secure_directory(data_dir).map_err(CryptoError::CreateDataDirectory)?; + + let key_path = configured_path + .map(Path::to_path_buf) + .unwrap_or_else(|| data_dir.join("master.key")); + + if key_path.exists() { + if configured_path.is_none() { + let parent = key_path.parent().unwrap_or_else(|| Path::new(".")); + sync_parent(parent).map_err(|source| CryptoError::CreateMasterKey { + path: key_path.clone(), + source, + })?; + } + return read_master_key(&key_path); + } + + if configured_path.is_some() || database_existed { + return Err(CryptoError::MissingMasterKey(key_path)); + } + + create_master_key_atomically(&key_path, sync_parent) +} + +fn aad(purpose: &str, record_id: &str) -> Vec { + let mut encoded = Vec::with_capacity(24 + purpose.len() + record_id.len()); + encoded.extend_from_slice(b"executor-aad"); + encoded.push(ENVELOPE_VERSION); + append_length_prefixed(&mut encoded, purpose.as_bytes()); + append_length_prefixed(&mut encoded, record_id.as_bytes()); + encoded +} + +fn append_length_prefixed(encoded: &mut Vec, value: &[u8]) { + encoded.extend_from_slice(&(value.len() as u64).to_be_bytes()); + encoded.extend_from_slice(value); +} + +fn read_master_key(path: &Path) -> Result<[u8; MASTER_KEY_LENGTH], CryptoError> { + let bytes = fs::read(path).map_err(|source| CryptoError::ReadMasterKey { + path: path.to_path_buf(), + source, + })?; + bytes + .try_into() + .map_err(|bytes: Vec| CryptoError::InvalidMasterKeyLength { + path: path.to_path_buf(), + length: bytes.len(), + }) +} + +fn create_master_key_atomically( + path: &Path, + sync_parent: &impl Fn(&Path) -> Result<(), std::io::Error>, +) -> Result<[u8; MASTER_KEY_LENGTH], CryptoError> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(CryptoError::CreateDataDirectory)?; + secure_directory(parent).map_err(CryptoError::CreateDataDirectory)?; + + let mut key = [0_u8; MASTER_KEY_LENGTH]; + SystemOsRng.fill_bytes(&mut key); + let temporary_path = parent.join(format!(".master-key-{}.tmp", Uuid::new_v4())); + + let create_result = (|| -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + secure_file_options(&mut options); + let mut file = options.open(&temporary_path)?; + file.write_all(&key)?; + file.sync_all()?; + publish_master_key(&temporary_path, path, parent, sync_parent) + })(); + + match create_result { + Ok(KeyPublishOutcome::Published) => Ok(key), + Ok(KeyPublishOutcome::Existing) => { + sync_parent(parent).map_err(|source| CryptoError::CreateMasterKey { + path: path.to_path_buf(), + source, + })?; + read_master_key(path) + } + Err(source) => Err(CryptoError::CreateMasterKey { + path: path.to_path_buf(), + source, + }), + } +} + +fn publish_master_key( + temporary_path: &Path, + path: &Path, + parent: &Path, + sync_parent: &impl Fn(&Path) -> Result<(), std::io::Error>, +) -> Result { + match fs::hard_link(temporary_path, path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + fs::remove_file(temporary_path)?; + return Ok(KeyPublishOutcome::Existing); + } + Err(error) => { + let _ = fs::remove_file(temporary_path); + return Err(error); + } + } + + if let Err(error) = sync_parent(parent) { + let _ = fs::remove_file(temporary_path); + return Err(error); + } + fs::remove_file(temporary_path)?; + sync_parent(parent)?; + Ok(KeyPublishOutcome::Published) +} + +fn sync_directory(path: &Path) -> Result<(), std::io::Error> { + fs::File::open(path)?.sync_all() +} + +#[cfg(unix)] +fn secure_directory(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) +} + +#[cfg(not(unix))] +fn secure_directory(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(unix)] +fn secure_file_options(options: &mut OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); +} + +#[cfg(not(unix))] +fn secure_file_options(_options: &mut OpenOptions) {} + +#[cfg(test)] +mod tests { + use super::{ + CryptoError, KeyPublishOutcome, load_or_create_master_key_with_sync, publish_master_key, + }; + use std::{cell::Cell, fs, io::ErrorKind}; + + #[test] + fn parent_sync_failures_are_propagated_after_key_publication() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let temporary_path = directory.path().join("temporary.key"); + let key_path = directory.path().join("master.key"); + fs::write(&temporary_path, [1_u8; 32]).expect("temporary key should be written"); + + let error = publish_master_key(&temporary_path, &key_path, directory.path(), &|_| { + Err(std::io::Error::other("injected directory sync failure")) + }) + .expect_err("directory sync failure must be returned"); + assert_eq!(error.kind(), ErrorKind::Other); + assert!(key_path.exists()); + } + + #[test] + fn only_an_already_existing_link_uses_the_existing_key_path() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let temporary_path = directory.path().join("temporary.key"); + let key_path = directory.path().join("master.key"); + fs::write(&temporary_path, [1_u8; 32]).expect("temporary key should be written"); + fs::write(&key_path, [2_u8; 32]).expect("existing key should be written"); + + let outcome = publish_master_key(&temporary_path, &key_path, directory.path(), &|_| { + panic!("an existing link must not run the publication sync path") + }) + .expect("already existing key should be detected"); + assert_eq!(outcome, KeyPublishOutcome::Existing); + assert!(!temporary_path.exists()); + assert_eq!( + fs::read(key_path).expect("existing key should be readable"), + [2_u8; 32] + ); + } + + #[test] + fn failed_publication_sync_is_retried_before_the_existing_key_is_read() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let sync_attempts = Cell::new(0); + let sync_parent = |_: &std::path::Path| { + let attempt = sync_attempts.get() + 1; + sync_attempts.set(attempt); + if attempt <= 2 { + Err(std::io::Error::other("injected directory sync failure")) + } else { + Ok(()) + } + }; + + let first = + load_or_create_master_key_with_sync(directory.path(), false, None, &sync_parent) + .expect_err("the publication sync failure must be returned"); + assert!(matches!(first, CryptoError::CreateMasterKey { .. })); + assert!(directory.path().join("master.key").exists()); + assert_eq!(sync_attempts.get(), 1); + + let second = + load_or_create_master_key_with_sync(directory.path(), false, None, &sync_parent) + .expect_err("the next open must retry and return the sync failure"); + assert!(matches!(second, CryptoError::CreateMasterKey { .. })); + assert_eq!(sync_attempts.get(), 2); + + let key = load_or_create_master_key_with_sync(directory.path(), false, None, &sync_parent) + .expect("the key may be read only after the parent sync succeeds"); + assert_eq!(sync_attempts.get(), 3); + assert_eq!( + fs::read(directory.path().join("master.key")) + .expect("the published key should be readable"), + key + ); + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 000000000..8cece61a9 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,286 @@ +use std::{ + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, + time::Duration, +}; + +use sqlx::{ + SqlitePool, + migrate::Migrator, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, +}; +use thiserror::Error; + +use crate::{ + AppConfig, + crypto::{CryptoError, Keyring, generate_secret, load_or_create_master_key}, + unix_timestamp, +}; + +static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); +const SENTINEL_KEY: &str = "boot_sentinel"; +const SENTINEL_PLAINTEXT: &[u8] = b"executor-boot-sentinel-v1"; +pub(crate) const SETUP_TOKEN_TTL_SECONDS: i64 = 15 * 60; + +#[derive(Debug, Error)] +pub enum DatabaseError { + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error("could not configure the SQLite database: {0}")] + Configuration(#[source] sqlx::Error), + #[error("could not run embedded SQLite migrations: {0}")] + Migration(#[source] sqlx::migrate::MigrateError), + #[error("the database boot sentinel is missing; refusing to guess the instance key")] + MissingBootSentinel, + #[error( + "the database boot sentinel could not be authenticated; the master key is wrong or the database was modified" + )] + InvalidBootSentinel, + #[error("could not initialize the setup claim: {0}")] + SetupClaim(#[source] sqlx::Error), + #[error("another Executor process is already using {0}")] + AlreadyRunning(PathBuf), + #[error("could not lock the Executor data directory at {path}: {source}")] + InstanceLock { + path: PathBuf, + source: std::io::Error, + }, + #[error("could not secure the SQLite database file at {path}: {source}")] + SecureDatabaseFile { + path: PathBuf, + source: std::io::Error, + }, +} + +#[derive(Clone)] +pub(crate) struct Database { + pub(crate) pool: SqlitePool, + pub(crate) keyring: Keyring, + _instance_lock: Arc, +} + +struct InstanceLock { + _file: File, +} + +pub(crate) struct OpenedDatabase { + pub(crate) database: Database, + pub(crate) setup_token: Option, +} + +impl Database { + pub(crate) async fn open(config: &AppConfig) -> Result { + let instance_lock = Arc::new(InstanceLock::acquire(&config.data_dir)?); + let database_path = config.data_dir.join("executor.db"); + let database_existed = database_path.exists(); + let master_key = load_or_create_master_key( + &config.data_dir, + database_existed, + config.master_key_file.as_deref(), + )?; + let keyring = Keyring::from_master_key(master_key)?; + secure_database_file(&database_path)?; + let options = sqlite_options(database_path)?; + let pool = SqlitePoolOptions::new() + .max_connections(8) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(options) + .await + .map_err(DatabaseError::Configuration)?; + + MIGRATOR + .run(&pool) + .await + .map_err(DatabaseError::Migration)?; + verify_or_create_sentinel(&pool, &keyring, database_existed).await?; + let setup_token = issue_setup_token_if_needed(&pool, &keyring).await?; + + Ok(OpenedDatabase { + database: Database { + pool, + keyring, + _instance_lock: instance_lock, + }, + setup_token, + }) + } +} + +impl InstanceLock { + fn acquire(data_dir: &Path) -> Result { + fs::create_dir_all(data_dir).map_err(CryptoError::CreateDataDirectory)?; + secure_data_directory(data_dir).map_err(CryptoError::CreateDataDirectory)?; + let path = data_dir.join("executor.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + secure_open_options(&mut options); + let file = options + .open(&path) + .map_err(|source| DatabaseError::InstanceLock { + path: path.clone(), + source, + })?; + secure_file_permissions(&path).map_err(|source| DatabaseError::InstanceLock { + path: path.clone(), + source, + })?; + match file.try_lock() { + Ok(()) => Ok(Self { _file: file }), + Err(std::fs::TryLockError::WouldBlock) => { + Err(DatabaseError::AlreadyRunning(data_dir.to_path_buf())) + } + Err(std::fs::TryLockError::Error(source)) => { + Err(DatabaseError::InstanceLock { path, source }) + } + } + } +} + +fn sqlite_options(database_path: PathBuf) -> Result { + let url = format!("sqlite://{}", database_path.display()); + SqliteConnectOptions::from_str(&url) + .map(|options| { + options + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Full) + .busy_timeout(Duration::from_secs(5)) + }) + .map_err(DatabaseError::Configuration) +} + +async fn verify_or_create_sentinel( + pool: &SqlitePool, + keyring: &Keyring, + database_existed: bool, +) -> Result<(), DatabaseError> { + let sentinel = + sqlx::query_scalar::<_, Vec>("SELECT value FROM instance_metadata WHERE key = ?") + .bind(SENTINEL_KEY) + .fetch_optional(pool) + .await + .map_err(DatabaseError::Configuration)?; + + match sentinel { + Some(sealed) => { + let plaintext = keyring + .decrypt("boot-sentinel", "singleton", &sealed) + .map_err(|_| DatabaseError::InvalidBootSentinel)?; + if plaintext != SENTINEL_PLAINTEXT { + return Err(DatabaseError::InvalidBootSentinel); + } + Ok(()) + } + None if database_existed && application_state_exists(pool).await? => { + Err(DatabaseError::MissingBootSentinel) + } + None => { + let sealed = keyring.encrypt("boot-sentinel", "singleton", SENTINEL_PLAINTEXT)?; + sqlx::query("INSERT INTO instance_metadata (key, value) VALUES (?, ?)") + .bind(SENTINEL_KEY) + .bind(sealed) + .execute(pool) + .await + .map_err(DatabaseError::Configuration)?; + Ok(()) + } + } +} + +async fn application_state_exists(pool: &SqlitePool) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM setup_state) \ + OR EXISTS(SELECT 1 FROM admins) \ + OR EXISTS(SELECT 1 FROM admin_sessions) \ + OR EXISTS(SELECT 1 FROM api_tokens) \ + OR EXISTS(SELECT 1 FROM instance_metadata WHERE key <> ?)", + ) + .bind(SENTINEL_KEY) + .fetch_one(pool) + .await + .map(|exists| exists != 0) + .map_err(DatabaseError::Configuration) +} + +async fn issue_setup_token_if_needed( + pool: &SqlitePool, + keyring: &Keyring, +) -> Result, DatabaseError> { + let has_admin = + sqlx::query_scalar::<_, i64>("SELECT EXISTS(SELECT 1 FROM admins WHERE id = 1)") + .fetch_one(pool) + .await + .map_err(DatabaseError::SetupClaim)? + != 0; + + if has_admin { + return Ok(None); + } + + let token = generate_secret("set_"); + let digest = keyring.digest("setup-token", token.as_bytes()); + sqlx::query( + "INSERT INTO setup_state (id, token_digest, created_at, used_at) VALUES (1, ?, ?, NULL) \ + ON CONFLICT(id) DO UPDATE SET token_digest = excluded.token_digest, created_at = excluded.created_at, used_at = NULL", + ) + .bind(digest.to_vec()) + .bind(unix_timestamp()) + .execute(pool) + .await + .map_err(DatabaseError::SetupClaim)?; + + Ok(Some(token)) +} + +fn secure_database_file(path: &Path) -> Result<(), DatabaseError> { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + secure_open_options(&mut options); + options + .open(path) + .map_err(|source| DatabaseError::SecureDatabaseFile { + path: path.to_path_buf(), + source, + })?; + secure_file_permissions(path).map_err(|source| DatabaseError::SecureDatabaseFile { + path: path.to_path_buf(), + source, + }) +} + +#[cfg(unix)] +fn secure_data_directory(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) +} + +#[cfg(not(unix))] +fn secure_data_directory(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(unix)] +fn secure_open_options(options: &mut OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); +} + +#[cfg(not(unix))] +fn secure_open_options(_options: &mut OpenOptions) {} + +#[cfg(unix)] +fn secure_file_permissions(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn secure_file_permissions(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..25021f783 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,165 @@ +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::Router; +use directories::ProjectDirs; +use ipnet::IpNet; +use sqlx::SqlitePool; +use thiserror::Error; +use url::Url; + +mod api; +pub mod crypto; +mod database; +pub mod web_assets; + +pub use database::DatabaseError; + +pub const DEFAULT_BIND_ADDRESS: &str = "127.0.0.1:4788"; +pub const DEFAULT_ORIGIN: &str = "http://127.0.0.1:4788"; + +#[derive(Clone)] +pub struct AppConfig { + pub(crate) data_dir: PathBuf, + pub(crate) master_key_file: Option, + pub(crate) origin: Url, + pub(crate) session_ttl_seconds: i64, + pub(crate) trusted_proxies: Arc<[IpNet]>, +} + +impl AppConfig { + pub fn new(data_dir: PathBuf) -> Self { + Self { + data_dir, + master_key_file: None, + origin: Url::parse(DEFAULT_ORIGIN).expect("the default public origin is valid"), + session_ttl_seconds: 8 * 60 * 60, + trusted_proxies: Arc::from([]), + } + } + + pub fn system_default() -> Result { + let project_dirs = + ProjectDirs::from("dev", "Executor", "Executor").ok_or(ConfigError::NoDataDirectory)?; + Ok(Self::new(project_dirs.data_local_dir().to_path_buf())) + } + + pub fn with_master_key_file(mut self, master_key_file: Option) -> Self { + self.master_key_file = master_key_file; + self + } + + pub fn with_trusted_proxies(mut self, trusted_proxies: Vec) -> Self { + self.trusted_proxies = Arc::from(trusted_proxies); + self + } + + pub fn with_origin(mut self, origin: &str) -> Result { + self.origin = parse_public_origin(origin)?; + Ok(self) + } + + pub fn with_default_origin_for_bind(self, bind: SocketAddr) -> Result { + if bind.ip().is_unspecified() { + return Err(ConfigError::PublicOriginRequiredForUnspecifiedBind(bind)); + } + self.with_origin(&format!("http://{bind}")) + } + + pub fn public_origin(&self) -> String { + self.origin.origin().ascii_serialization() + } + + pub fn validate_bind( + &self, + bind: SocketAddr, + allow_unsafe_http_non_loopback: bool, + ) -> Result<(), ConfigError> { + if self.origin.scheme() == "http" + && !bind.ip().is_loopback() + && !allow_unsafe_http_non_loopback + { + return Err(ConfigError::UnsafePlaintextNonLoopbackBind(bind)); + } + Ok(()) + } +} + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("could not determine a platform data directory")] + NoDataDirectory, + #[error("the public origin is not a valid URL: {0}")] + InvalidPublicOrigin(#[source] url::ParseError), + #[error("the public origin must use http or https")] + UnsupportedPublicOriginScheme, + #[error("the public origin must contain only scheme, host, and optional port")] + PublicOriginHasExtraComponents, + #[error( + "--public-origin is required when --bind uses unspecified address {0}; Executor cannot infer the browser-visible host" + )] + PublicOriginRequiredForUnspecifiedBind(SocketAddr), + #[error( + "refusing plaintext HTTP on non-loopback bind {0}; use --allow-unsafe-http-non-loopback only when transport security is provided elsewhere" + )] + UnsafePlaintextNonLoopbackBind(SocketAddr), +} + +pub struct ExecutorApp { + router: Router, + setup_token: Option, + pool: SqlitePool, +} + +impl ExecutorApp { + pub async fn open(config: AppConfig) -> Result { + let opened = database::Database::open(&config).await?; + let pool = opened.database.pool.clone(); + let dummy_password_hash = crypto::hash_password("executor-dummy-login-password")?; + let router = api::router(opened.database, &config, dummy_password_hash); + Ok(Self { + router, + setup_token: opened.setup_token, + pool, + }) + } + + pub fn router(&self) -> Router { + self.router.clone() + } + + pub fn setup_token(&self) -> Option<&str> { + self.setup_token.as_deref() + } + + pub fn pool(&self) -> &SqlitePool { + &self.pool + } +} + +fn parse_public_origin(origin: &str) -> Result { + let parsed = Url::parse(origin).map_err(ConfigError::InvalidPublicOrigin)?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host().is_none() { + return Err(ConfigError::UnsupportedPublicOriginScheme); + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(ConfigError::PublicOriginHasExtraComponents); + } + Ok(parsed) +} + +pub(crate) fn unix_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time must be after the Unix epoch") + .as_secs() as i64 +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 000000000..10e688df7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,94 @@ +use std::{net::SocketAddr, path::PathBuf}; + +use anyhow::{Context, Result}; +use clap::{Args, Parser, Subcommand}; +use executor::{AppConfig, DEFAULT_BIND_ADDRESS, ExecutorApp}; +use ipnet::IpNet; +use tokio::net::TcpListener; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(name = "executor", about = "The local Executor gateway")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Server(ServerArgs), +} + +#[derive(Args)] +struct ServerArgs { + #[arg(long, default_value = DEFAULT_BIND_ADDRESS)] + bind: SocketAddr, + #[arg(long, env = "EXECUTOR_DATA_DIR")] + data_dir: Option, + #[arg(long, env = "EXECUTOR_MASTER_KEY_FILE")] + master_key_file: Option, + #[arg(long, env = "EXECUTOR_PUBLIC_ORIGIN")] + public_origin: Option, + #[arg( + long = "trusted-proxy", + env = "EXECUTOR_TRUSTED_PROXIES", + value_delimiter = ',', + value_name = "CIDR", + help = "Trust X-Forwarded-For only from these proxy CIDRs (repeat or comma-separate)" + )] + trusted_proxies: Vec, + #[arg(long)] + allow_unsafe_http_non_loopback: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("executor=info")), + ) + .init(); + + match Cli::parse().command { + Command::Server(args) => run_server(args).await, + } +} + +async fn run_server(args: ServerArgs) -> Result<()> { + let mut config = match args.data_dir { + Some(data_dir) => AppConfig::new(data_dir), + None => AppConfig::system_default()?, + }; + config = config + .with_master_key_file(args.master_key_file) + .with_trusted_proxies(args.trusted_proxies); + let listener = TcpListener::bind(args.bind) + .await + .with_context(|| format!("could not bind Executor to {}", args.bind))?; + let bound_address = listener + .local_addr() + .context("could not determine Executor's bound address")?; + config = match args.public_origin { + Some(public_origin) => config.with_origin(&public_origin)?, + None => config.with_default_origin_for_bind(bound_address)?, + }; + config.validate_bind(bound_address, args.allow_unsafe_http_non_loopback)?; + let app = ExecutorApp::open(config.clone()) + .await + .context("Executor could not initialize")?; + + if let Some(setup_token) = app.setup_token() { + println!("Complete first-boot setup at:"); + println!("{}/setup#token={setup_token}", config.public_origin()); + } + + info!(address = %bound_address, "Executor is listening"); + axum::serve( + listener, + app.router() + .into_make_service_with_connect_info::(), + ) + .await + .context("Executor server stopped unexpectedly") +} diff --git a/src/web_assets.rs b/src/web_assets.rs new file mode 100644 index 000000000..194e8d3c6 --- /dev/null +++ b/src/web_assets.rs @@ -0,0 +1,10 @@ +use std::path::Path; + +pub const WEB_DISTRIBUTION_DIRECTORY: &str = "web/build"; + +pub fn distribution_exists(repository_root: &Path) -> bool { + repository_root + .join(WEB_DISTRIBUTION_DIRECTORY) + .join("index.html") + .is_file() +} diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 000000000..5d158f453 --- /dev/null +++ b/tests/config.rs @@ -0,0 +1,68 @@ +use std::net::SocketAddr; + +use executor::{AppConfig, ConfigError}; + +#[test] +fn public_origins_are_canonicalized_and_reject_extra_components() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()) + .with_origin("HTTPS://Example.COM:443/") + .expect("origin should be valid"); + assert_eq!(config.public_origin(), "https://example.com"); + + for invalid in [ + "https://user@example.com", + "https://example.com/path", + "https://example.com?query=1", + "https://example.com#fragment", + ] { + assert!(matches!( + AppConfig::new(directory.path().to_path_buf()).with_origin(invalid), + Err(ConfigError::PublicOriginHasExtraComponents) + )); + } + assert!(matches!( + AppConfig::new(directory.path().to_path_buf()).with_origin("ftp://example.com"), + Err(ConfigError::UnsupportedPublicOriginScheme) + )); +} + +#[test] +fn plaintext_http_requires_an_explicit_unsafe_non_loopback_override() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let http = AppConfig::new(directory.path().to_path_buf()); + let loopback: SocketAddr = "127.0.0.1:4788".parse().expect("address should parse"); + let public: SocketAddr = "0.0.0.0:4788".parse().expect("address should parse"); + assert!(http.validate_bind(loopback, false).is_ok()); + assert!(matches!( + http.validate_bind(public, false), + Err(ConfigError::UnsafePlaintextNonLoopbackBind(_)) + )); + assert!(http.validate_bind(public, true).is_ok()); + + let https = AppConfig::new(directory.path().to_path_buf()) + .with_origin("https://executor.example.com") + .expect("HTTPS origin should be valid"); + assert!(https.validate_bind(public, false).is_ok()); +} + +#[test] +fn default_public_origin_tracks_the_actual_bind_address() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let loopback: SocketAddr = "127.0.0.1:5888".parse().expect("address should parse"); + let config = AppConfig::new(directory.path().to_path_buf()) + .with_default_origin_for_bind(loopback) + .expect("loopback bind should produce an origin"); + assert_eq!(config.public_origin(), "http://127.0.0.1:5888"); + + let unspecified: SocketAddr = "0.0.0.0:5888".parse().expect("address should parse"); + assert!(matches!( + AppConfig::new(directory.path().to_path_buf()).with_default_origin_for_bind(unspecified), + Err(ConfigError::PublicOriginRequiredForUnspecifiedBind(_)) + )); + + let explicit = AppConfig::new(directory.path().to_path_buf()) + .with_origin("https://executor.example.com") + .expect("explicit public origin should remain supported"); + assert_eq!(explicit.public_origin(), "https://executor.example.com"); +} diff --git a/tests/control_plane.rs b/tests/control_plane.rs new file mode 100644 index 000000000..64a233819 --- /dev/null +++ b/tests/control_plane.rs @@ -0,0 +1,1174 @@ +use std::{fs, net::SocketAddr, sync::Arc}; + +use axum::{ + Router, + body::Body, + extract::ConnectInfo, + http::{Method, Request, Response, StatusCode, header}, +}; +use executor::{AppConfig, DatabaseError, ExecutorApp}; +use http_body_util::BodyExt; +use ipnet::IpNet; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const PASSWORD: &str = "correct-horse-battery-staple"; + +struct TestExecutor { + _directory: TempDir, + app: Arc, +} + +struct AdminSession { + cookie: String, + csrf: String, +} + +impl TestExecutor { + async fn new() -> Self { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test Executor should open"); + Self { + _directory: directory, + app: Arc::new(app), + } + } + + async fn with_trusted_proxies(networks: &[&str]) -> Self { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let trusted_proxies = networks + .iter() + .map(|network| { + network + .parse::() + .expect("trusted proxy network should parse") + }) + .collect(); + let app = ExecutorApp::open( + AppConfig::new(directory.path().to_path_buf()).with_trusted_proxies(trusted_proxies), + ) + .await + .expect("test Executor should open"); + Self { + _directory: directory, + app: Arc::new(app), + } + } + + fn router(&self) -> Router { + self.app.router() + } + + fn setup_token(&self) -> String { + self.app + .setup_token() + .expect("fresh test app should issue a setup token") + .to_owned() + } + + async fn setup_admin(&self) { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": self.setup_token(), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + } + + async fn login(&self) -> AdminSession { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = response_cookie_header(&response); + let body = response_json(response).await; + let csrf = body["csrfToken"] + .as_str() + .expect("login should reveal a CSRF token") + .to_owned(); + AdminSession { cookie, csrf } + } +} + +#[tokio::test] +async fn migrations_and_sqlite_safety_pragmas_are_active() { + let executor = TestExecutor::new().await; + let table_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN \ + ('instance_metadata', 'setup_state', 'admins', 'admin_sessions', 'api_tokens')", + ) + .fetch_one(executor.app.pool()) + .await + .expect("schema query should succeed"); + assert_eq!(table_count, 5); + + let foreign_keys = sqlx::query_scalar::<_, i64>("PRAGMA foreign_keys") + .fetch_one(executor.app.pool()) + .await + .expect("foreign_keys pragma should be readable"); + let journal_mode = sqlx::query_scalar::<_, String>("PRAGMA journal_mode") + .fetch_one(executor.app.pool()) + .await + .expect("journal mode should be readable"); + let synchronous = sqlx::query_scalar::<_, i64>("PRAGMA synchronous") + .fetch_one(executor.app.pool()) + .await + .expect("synchronous pragma should be readable"); + assert_eq!(foreign_keys, 1); + assert_eq!(journal_mode, "wal"); + assert_eq!(synchronous, 2); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let directory_mode = fs::metadata(executor._directory.path()) + .expect("data directory metadata should be readable") + .permissions() + .mode() + & 0o777; + let key_mode = fs::metadata(executor._directory.path().join("master.key")) + .expect("master key metadata should be readable") + .permissions() + .mode() + & 0o777; + let database_mode = fs::metadata(executor._directory.path().join("executor.db")) + .expect("database metadata should be readable") + .permissions() + .mode() + & 0o777; + let lock_mode = fs::metadata(executor._directory.path().join("executor.lock")) + .expect("lock metadata should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(key_mode, 0o600); + assert_eq!(database_mode, 0o600); + assert_eq!(lock_mode, 0o600); + } +} + +#[tokio::test] +async fn one_process_holds_the_data_directory_lock_for_its_lifetime() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let first = ExecutorApp::open(config.clone()) + .await + .expect("first process should acquire the lock"); + let Err(error) = ExecutorApp::open(config.clone()).await else { + panic!("a second process must not acquire the same data directory"); + }; + assert!(matches!(error, DatabaseError::AlreadyRunning(_))); + + first.pool().close().await; + drop(first); + let reopened = ExecutorApp::open(config) + .await + .expect("dropping the app should release the lock"); + reopened.pool().close().await; +} + +#[tokio::test] +async fn initialized_database_fails_closed_for_missing_or_wrong_keys() { + let missing_key_directory = tempfile::tempdir().expect("temporary directory should be created"); + let missing_config = AppConfig::new(missing_key_directory.path().to_path_buf()); + let missing_app = ExecutorApp::open(missing_config.clone()) + .await + .expect("initial open should succeed"); + missing_app.pool().close().await; + drop(missing_app); + fs::remove_file(missing_key_directory.path().join("master.key")) + .expect("master key should be removable in the test"); + let Err(missing_error) = ExecutorApp::open(missing_config).await else { + panic!("missing key must fail closed"); + }; + assert!(matches!( + missing_error, + DatabaseError::Crypto(executor::crypto::CryptoError::MissingMasterKey(_)) + )); + + let wrong_key_directory = tempfile::tempdir().expect("temporary directory should be created"); + let wrong_config = AppConfig::new(wrong_key_directory.path().to_path_buf()); + let wrong_app = ExecutorApp::open(wrong_config.clone()) + .await + .expect("initial open should succeed"); + wrong_app.pool().close().await; + drop(wrong_app); + fs::write(wrong_key_directory.path().join("master.key"), [9_u8; 32]) + .expect("test should replace the key"); + let Err(wrong_error) = ExecutorApp::open(wrong_config).await else { + panic!("wrong key must fail closed"); + }; + assert!(matches!(wrong_error, DatabaseError::InvalidBootSentinel)); +} + +#[tokio::test] +async fn tampered_boot_sentinel_fails_closed() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + let mut sentinel = sqlx::query_scalar::<_, Vec>( + "SELECT value FROM instance_metadata WHERE key = 'boot_sentinel'", + ) + .fetch_one(app.pool()) + .await + .expect("sentinel should exist"); + let last = sentinel.len() - 1; + sentinel[last] ^= 0x01; + sqlx::query("UPDATE instance_metadata SET value = ? WHERE key = 'boot_sentinel'") + .bind(sentinel) + .execute(app.pool()) + .await + .expect("sentinel should be updateable in the test"); + app.pool().close().await; + drop(app); + + let Err(error) = ExecutorApp::open(config).await else { + panic!("tampered sentinel must fail closed"); + }; + assert!(matches!(error, DatabaseError::InvalidBootSentinel)); +} + +#[tokio::test] +async fn interrupted_empty_initialization_recreates_the_boot_sentinel() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + sqlx::query("DELETE FROM setup_state") + .execute(app.pool()) + .await + .expect("test should remove setup state"); + sqlx::query("DELETE FROM instance_metadata WHERE key = 'boot_sentinel'") + .execute(app.pool()) + .await + .expect("test should remove the sentinel"); + app.pool().close().await; + drop(app); + + let recovered = ExecutorApp::open(config) + .await + .expect("empty post-migration state should recover"); + assert!(recovered.setup_token().is_some()); + let sentinel_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM instance_metadata WHERE key = 'boot_sentinel'", + ) + .fetch_one(recovered.pool()) + .await + .expect("sentinel should be readable"); + assert_eq!(sentinel_count, 1); +} + +#[tokio::test] +async fn missing_sentinel_with_application_state_still_fails_closed() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + sqlx::query("DELETE FROM instance_metadata WHERE key = 'boot_sentinel'") + .execute(app.pool()) + .await + .expect("test should remove the sentinel"); + app.pool().close().await; + drop(app); + + let Err(error) = ExecutorApp::open(config).await else { + panic!("setup state without a sentinel must fail closed"); + }; + assert!(matches!(error, DatabaseError::MissingBootSentinel)); +} + +#[tokio::test] +async fn expired_setup_tokens_are_rejected_before_password_work() { + let executor = TestExecutor::new().await; + sqlx::query("UPDATE setup_state SET created_at = 0 WHERE id = 1") + .execute(executor.app.pool()) + .await + .expect("test should expire the setup token"); + let response = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": executor.setup_token(), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + + assert_error(response, StatusCode::UNAUTHORIZED, "unauthorized").await; +} + +#[tokio::test] +async fn concurrent_setup_claims_create_exactly_one_admin() { + let executor = TestExecutor::new().await; + let token = executor.setup_token(); + let first_headers = [(header::ORIGIN.as_str(), ORIGIN)]; + let second_headers = [(header::ORIGIN.as_str(), ORIGIN)]; + let first = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ "setupToken": token, "username": "first", "password": PASSWORD }), + &first_headers, + ); + let second = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ "setupToken": executor.setup_token(), "username": "second", "password": PASSWORD }), + &second_headers, + ); + let (first_response, second_response) = tokio::join!(first, second); + let mut statuses = [first_response.status(), second_response.status()]; + statuses.sort_by_key(|status| status.as_u16()); + assert_eq!(statuses, [StatusCode::CREATED, StatusCode::CONFLICT]); + + let admin_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM admins") + .fetch_one(executor.app.pool()) + .await + .expect("admin count should be readable"); + assert_eq!(admin_count, 1); +} + +#[tokio::test] +async fn sessions_enforce_cookie_csrf_origin_and_logout() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + + let session_response = send_empty( + executor.router(), + Method::GET, + "/api/v1/session", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(session_response.status(), StatusCode::OK); + + let missing_origin = create_token_request(&executor, &admin, None, Some(&admin.csrf)).await; + assert_error(missing_origin, StatusCode::FORBIDDEN, "invalid_origin").await; + + let wrong_origin = create_token_request( + &executor, + &admin, + Some("https://attacker.invalid"), + Some(&admin.csrf), + ) + .await; + assert_error(wrong_origin, StatusCode::FORBIDDEN, "invalid_origin").await; + + let missing_csrf = create_token_request(&executor, &admin, Some(ORIGIN), None).await; + assert_error(missing_csrf, StatusCode::FORBIDDEN, "invalid_csrf").await; + + let wrong_csrf = + create_token_request(&executor, &admin, Some(ORIGIN), Some("csrf_wrong")).await; + assert_error(wrong_csrf, StatusCode::FORBIDDEN, "invalid_csrf").await; + + let created = create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(created.status(), StatusCode::CREATED); + + let logout = send_empty( + executor.router(), + Method::DELETE, + "/api/v1/session", + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(logout.status(), StatusCode::NO_CONTENT); + + let after_logout = send_empty( + executor.router(), + Method::GET, + "/api/v1/session", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_error(after_logout, StatusCode::UNAUTHORIZED, "unauthorized").await; +} + +#[tokio::test] +async fn usernames_are_trimmed_and_credential_fields_are_bounded() { + let executor = TestExecutor::new().await; + let setup = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": executor.setup_token(), + "username": " admin ", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let stored_username = + sqlx::query_scalar::<_, String>("SELECT username FROM admins WHERE id = 1") + .fetch_one(executor.app.pool()) + .await + .expect("admin should be stored"); + assert_eq!(stored_username, "admin"); + + let login = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": " admin ", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(login.status(), StatusCode::OK); + + let oversized_password = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": "x".repeat(1025) }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_error( + oversized_password, + StatusCode::BAD_REQUEST, + "invalid_password", + ) + .await; + + let oversized_username = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "x".repeat(65), "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_error( + oversized_username, + StatusCode::BAD_REQUEST, + "invalid_username", + ) + .await; +} + +#[tokio::test] +async fn https_public_origins_produce_secure_session_cookies() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open( + AppConfig::new(directory.path().to_path_buf()) + .with_origin("https://executor.example.com") + .expect("HTTPS origin should be valid"), + ) + .await + .expect("test Executor should open"); + let setup = send_json( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("setup token should exist"), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), "https://executor.example.com")], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let login = send_json( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), "https://executor.example.com")], + ) + .await; + assert_eq!(login.status(), StatusCode::OK); + assert!( + login + .headers() + .get_all(header::SET_COOKIE) + .iter() + .all(|value| { + value + .to_str() + .expect("set-cookie should be text") + .contains("Secure") + }) + ); +} + +#[tokio::test] +async fn password_work_is_non_waiting_and_bounded() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let headers = [(header::ORIGIN.as_str(), ORIGIN)]; + let login_body = json!({ "username": "missing", "password": PASSWORD }); + let first = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body.clone(), + &headers, + ); + let second = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body.clone(), + &headers, + ); + let third = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body.clone(), + &headers, + ); + let fourth = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body, + &headers, + ); + let responses = tokio::join!(first, second, third, fourth); + let statuses = [ + responses.0.status(), + responses.1.status(), + responses.2.status(), + responses.3.status(), + ]; + assert!(statuses.contains(&StatusCode::TOO_MANY_REQUESTS)); + assert!(statuses.iter().all(|status| { + matches!( + *status, + StatusCode::UNAUTHORIZED | StatusCode::TOO_MANY_REQUESTS + ) + })); +} + +#[tokio::test] +async fn login_rate_limit_uses_peer_ip_and_ignores_forwarded_headers() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let first_peer_ip = "192.0.2.10"; + + for attempt in 0..5 { + let peer: SocketAddr = format!("{first_peer_ip}:{}", 4100 + attempt) + .parse() + .expect("peer address should parse"); + let forwarded = format!("203.0.113.{}", attempt + 1); + let response = send_json_from( + executor.router(), + peer, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", &forwarded), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let limited_peer: SocketAddr = format!("{first_peer_ip}:4200") + .parse() + .expect("peer address should parse"); + let limited = send_json_from( + executor.router(), + limited_peer, + Method::POST, + "/api/v1/session", + json!({ "username": "another-missing-user", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "198.51.100.200"), + ], + ) + .await; + let retry_after = limited + .headers() + .get(header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + assert!(retry_after.is_some_and(|seconds| seconds > 0)); + assert_error(limited, StatusCode::TOO_MANY_REQUESTS, "login_rate_limited").await; + + let second_peer: SocketAddr = "192.0.2.11:4100" + .parse() + .expect("peer address should parse"); + let independent = send_json_from( + executor.router(), + second_peer, + Method::POST, + "/api/v1/session", + json!({ "username": "missing", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", first_peer_ip), + ], + ) + .await; + assert_eq!(independent.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn trusted_proxy_separates_clients_and_avoids_proxy_wide_lockout() { + let executor = TestExecutor::with_trusted_proxies(&["10.0.0.0/8"]).await; + executor.setup_admin().await; + let proxy: SocketAddr = "10.20.30.40:443" + .parse() + .expect("proxy address should parse"); + + for attempt in 0..5 { + let response = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.10"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let limited = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "still-missing", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.10"), + ], + ) + .await; + assert_error(limited, StatusCode::TOO_MANY_REQUESTS, "login_rate_limited").await; + + let independent_client = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "another-client", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.11"), + ], + ) + .await; + assert_eq!(independent_client.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn trusted_proxy_chain_walks_right_to_left_and_stops_at_the_client() { + let executor = TestExecutor::with_trusted_proxies(&["10.0.0.0/8", "192.168.0.0/16"]).await; + executor.setup_admin().await; + let edge_proxy: SocketAddr = "10.0.0.5:443".parse().expect("proxy address should parse"); + + for attempt in 0..5 { + let forwarded = format!("not-an-ip-{}, 203.0.113.20, 192.168.1.10", attempt + 1); + let response = send_json_from( + executor.router(), + edge_proxy, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", &forwarded), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let limited = send_json_from( + executor.router(), + edge_proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "still-missing", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "not-an-ip, 203.0.113.20, 192.168.1.10"), + ], + ) + .await; + assert_error(limited, StatusCode::TOO_MANY_REQUESTS, "login_rate_limited").await; + + let independent_client = send_json_from( + executor.router(), + edge_proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "another-client", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "not-an-ip, 203.0.113.21, 192.168.1.10"), + ], + ) + .await; + assert_eq!(independent_client.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn malformed_forwarded_chains_are_rejected_without_proxy_lockout() { + let executor = TestExecutor::with_trusted_proxies(&["10.0.0.0/8"]).await; + executor.setup_admin().await; + let proxy: SocketAddr = "10.0.0.8:443".parse().expect("proxy address should parse"); + + for attempt in 0..6 { + let malformed = format!("203.0.113.{}, not-an-ip", attempt + 1); + let response = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", &malformed), + ], + ) + .await; + assert_error(response, StatusCode::UNAUTHORIZED, "unauthorized").await; + } + + let missing = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "missing-header", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_error(missing, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let all_trusted = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "all-trusted", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "10.0.0.9"), + ], + ) + .await; + assert_error(all_trusted, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let valid_client = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "valid-client", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.100"), + ], + ) + .await; + assert_eq!(valid_client.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn successful_login_clears_the_in_process_rate_limit_window() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let headers = [(header::ORIGIN.as_str(), ORIGIN)]; + + for attempt in 0..4 { + let response = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &headers, + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let success = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &headers, + ) + .await; + assert_eq!(success.status(), StatusCode::OK); + + let after_success = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "missing-again", "password": PASSWORD }), + &headers, + ) + .await; + assert_eq!(after_success.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn oversized_api_bodies_are_rejected_with_the_stable_envelope() { + let executor = TestExecutor::new().await; + let response = send_raw( + executor.router(), + Method::POST, + "/api/v1/setup", + format!("{{\"padding\":\"{}\"}}", "x".repeat(17 * 1024)), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + + assert_error(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await; +} + +#[tokio::test] +async fn session_database_failures_are_internal_errors_not_logged_out_states() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + executor.app.pool().close().await; + + let response = send_empty( + executor.router(), + Method::GET, + "/api/v1/session", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_error( + response, + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + ) + .await; +} + +#[tokio::test] +async fn api_tokens_are_revealed_once_revocable_and_gateway_only() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let created = create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(created.status(), StatusCode::CREATED); + let created_body = response_json(created).await; + let token = created_body["token"] + .as_str() + .expect("create should reveal the token") + .to_owned(); + let token_id = created_body["id"] + .as_str() + .expect("create should return the ID") + .to_owned(); + + let listed = send_empty( + executor.router(), + Method::GET, + "/api/v1/tokens", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(listed.status(), StatusCode::OK); + let listed_body = response_json(listed).await; + assert_eq!(listed_body["tokens"].as_array().map(Vec::len), Some(1)); + assert!(listed_body.to_string().contains("maskedToken")); + assert!(!listed_body.to_string().contains(&token)); + + let gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[(header::AUTHORIZATION.as_str(), &format!("bEaReR {token}"))], + ) + .await; + assert_eq!(gateway.status(), StatusCode::OK); + let gateway_body = response_json(gateway).await; + assert_eq!(gateway_body["tokenId"], token_id); + let last_used = + sqlx::query_scalar::<_, Option>("SELECT last_used_at FROM api_tokens WHERE id = ?") + .bind(&token_id) + .fetch_one(executor.app.pool()) + .await + .expect("last-used timestamp should be readable"); + assert!(last_used.is_some()); + + let bearer_on_control_plane = send_empty( + executor.router(), + Method::GET, + "/api/v1/tokens", + &[(header::AUTHORIZATION.as_str(), &format!("Bearer {token}"))], + ) + .await; + assert_error( + bearer_on_control_plane, + StatusCode::UNAUTHORIZED, + "unauthorized", + ) + .await; + + let revoke = send_empty( + executor.router(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(revoke.status(), StatusCode::NO_CONTENT); + + let revoked_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[(header::AUTHORIZATION.as_str(), &format!("Bearer {token}"))], + ) + .await; + assert_error(revoked_gateway, StatusCode::UNAUTHORIZED, "unauthorized").await; +} + +#[tokio::test] +async fn token_names_are_counted_by_characters_and_stored_trimmed() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let unicode_name = "é".repeat(80); + let created = send_json( + executor.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": format!(" {unicode_name} ") }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let body = response_json(created).await; + assert_eq!(body["name"], unicode_name); +} + +#[tokio::test] +async fn health_bootstrap_and_errors_have_stable_shapes() { + let executor = TestExecutor::new().await; + let health = send_empty(executor.router(), Method::GET, "/healthz", &[]).await; + assert_eq!(health.status(), StatusCode::OK); + assert!(health.headers().contains_key("x-request-id")); + assert_eq!(response_json(health).await, json!({ "status": "ok" })); + + let before = send_empty(executor.router(), Method::GET, "/api/v1/bootstrap", &[]).await; + assert_eq!( + response_json(before).await, + json!({ "setupRequired": true, "authenticated": false }) + ); + + executor.setup_admin().await; + let admin = executor.login().await; + let after = send_empty( + executor.router(), + Method::GET, + "/api/v1/bootstrap", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!( + response_json(after).await, + json!({ "setupRequired": false, "authenticated": true }) + ); + + let missing = send_empty(executor.router(), Method::GET, "/missing", &[]).await; + assert_error(missing, StatusCode::NOT_FOUND, "not_found").await; +} + +async fn create_token_request( + executor: &TestExecutor, + admin: &AdminSession, + origin: Option<&str>, + csrf: Option<&str>, +) -> Response { + let mut headers = vec![(header::COOKIE.as_str(), admin.cookie.as_str())]; + if let Some(origin) = origin { + headers.push((header::ORIGIN.as_str(), origin)); + } + if let Some(csrf) = csrf { + headers.push(("x-executor-csrf", csrf)); + } + send_json( + executor.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Automation" }), + &headers, + ) + .await +} + +async fn send_json( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + send_raw(router, method, uri, body.to_string(), headers).await +} + +async fn send_json_from( + router: Router, + peer: SocketAddr, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + send_raw_with_peer(router, method, uri, body.to_string(), headers, Some(peer)).await +} + +async fn send_raw( + router: Router, + method: Method, + uri: &str, + body: String, + headers: &[(&str, &str)], +) -> Response { + send_raw_with_peer(router, method, uri, body, headers, None).await +} + +async fn send_raw_with_peer( + router: Router, + method: Method, + uri: &str, + body: String, + headers: &[(&str, &str)], + peer: Option, +) -> Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + let mut request = request + .body(Body::from(body)) + .expect("test request should be valid"); + if let Some(peer) = peer { + request.extensions_mut().insert(ConnectInfo(peer)); + } + router.oneshot(request).await.expect("router should answer") +} + +async fn send_empty( + router: Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::empty()) + .expect("test request should be valid"), + ) + .await + .expect("router should answer") +} + +fn response_cookie_header(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("set-cookie should be text") + .split(';') + .next() + .expect("set-cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +async fn assert_error(response: Response, status: StatusCode, code: &str) { + assert_eq!(response.status(), status); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-store")) + ); + let header_request_id = response + .headers() + .get("x-request-id") + .expect("error should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + let body = response_json(response).await; + assert_eq!(body["error"]["code"], code); + assert_eq!(body["error"]["requestId"], header_request_id); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| !message.is_empty()) + ); +} diff --git a/tests/crypto.rs b/tests/crypto.rs new file mode 100644 index 000000000..233a98c0a --- /dev/null +++ b/tests/crypto.rs @@ -0,0 +1,61 @@ +use executor::crypto::{CryptoError, Keyring}; + +#[test] +fn xchacha_ciphertext_rejects_tampering_and_wrong_aad() { + let keyring = Keyring::from_master_key([7_u8; 32]).expect("key derivation should succeed"); + let sealed = keyring + .encrypt("oauth-token", "source-1", b"refresh-secret") + .expect("encryption should succeed"); + assert_eq!(sealed.first(), Some(&1)); + + let plaintext = keyring + .decrypt("oauth-token", "source-1", &sealed) + .expect("matching AAD should decrypt"); + assert_eq!(plaintext, b"refresh-secret"); + + let mut tampered = sealed.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0x01; + assert!( + keyring + .decrypt("oauth-token", "source-1", &tampered) + .is_err() + ); + assert!(keyring.decrypt("oauth-token", "source-2", &sealed).is_err()); + assert!(keyring.decrypt("api-key", "source-1", &sealed).is_err()); +} + +#[test] +fn envelope_rejects_unsupported_versions() { + let keyring = Keyring::from_master_key([7_u8; 32]).expect("key derivation should succeed"); + let mut sealed = keyring + .encrypt("credential", "record-1", b"secret") + .expect("encryption should succeed"); + sealed[0] = 99; + + assert!(matches!( + keyring.decrypt("credential", "record-1", &sealed), + Err(CryptoError::UnsupportedEnvelopeVersion(99)) + )); +} + +#[test] +fn structured_aad_has_no_delimiter_collisions() { + let keyring = Keyring::from_master_key([7_u8; 32]).expect("key derivation should succeed"); + let sealed = keyring + .encrypt("a:b", "c", b"secret") + .expect("encryption should succeed"); + + assert!(keyring.decrypt("a", "b:c", &sealed).is_err()); +} + +#[test] +fn a_different_master_key_cannot_decrypt_ciphertext() { + let first = Keyring::from_master_key([1_u8; 32]).expect("key derivation should succeed"); + let second = Keyring::from_master_key([2_u8; 32]).expect("key derivation should succeed"); + let sealed = first + .encrypt("credential", "record-1", b"secret") + .expect("encryption should succeed"); + + assert!(second.decrypt("credential", "record-1", &sealed).is_err()); +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..3b462cb0c --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 000000000..7d74fe246 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 000000000..d59ac024f --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,7 @@ +{ + "plugins": ["prettier-plugin-svelte"], + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "all" +} diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 000000000..a1f5c8a75 --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode"] +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..97223058b --- /dev/null +++ b/web/README.md @@ -0,0 +1,29 @@ +# Executor web + +The Svelte 5 and SvelteKit dashboard is a client-only SPA intended to be served by the Rust binary. +It talks to the Rust control API over the same origin. Static asset embedding and Rust fallback +serving are a separate packaging boundary and are not wired yet. + +## Local checks + +Install workspace dependencies from the repository root with `bun install`. From this directory: + +```sh +bun run format:check +bun run check +bun run typecheck +bun run lint +bun run test +``` + +The static adapter is configured to write the production SPA to `web/build`. The future Rust +packaging step will embed that directory and serve `index.html` as the SPA fallback. + +## First boot + +Start the Rust server using the repository instructions. Open the `/setup#token=...` link printed +to the terminal. The dashboard removes the one-time token from browser history immediately, creates +the administrator, signs in, and opens `/sources`. + +After setup, sign in at `/login`. Create gateway credentials under `/tokens`. Plaintext API tokens +are shown once and are never stored by the browser. diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..7312c8954 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@executor-js/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "prepare": "svelte-kit sync", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "lint": "oxlint src && prettier --check .", + "test": "vitest run", + "typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@effect/vitest": "4.0.0-beta.59", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.67.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@testing-library/svelte": "^5.4.2", + "effect": "4.0.0-beta.59", + "jsdom": "^29.1.1", + "oxlint": "^1.71.0", + "prettier": "^3.8.4", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.56.4", + "svelte-check": "^4.7.0", + "typescript": "^6.0.3", + "vite": "^8.1.0", + "vitest": "^4.1.9" + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 000000000..a6911e55f --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,5 @@ +declare global { + namespace App {} +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 000000000..13f0663a7 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/DashboardShell.svelte b/web/src/lib/DashboardShell.svelte new file mode 100644 index 000000000..0709c3a57 --- /dev/null +++ b/web/src/lib/DashboardShell.svelte @@ -0,0 +1,99 @@ + + + + {title} | Executor + + + + +
+ + +
+ + {#if logoutError !== null} +
+ {/if} +
{@render children()}
+
+
diff --git a/web/src/lib/ErrorNotice.svelte b/web/src/lib/ErrorNotice.svelte new file mode 100644 index 000000000..159d20709 --- /dev/null +++ b/web/src/lib/ErrorNotice.svelte @@ -0,0 +1,14 @@ + + + diff --git a/web/src/lib/PlaceholderPanel.svelte b/web/src/lib/PlaceholderPanel.svelte new file mode 100644 index 000000000..411c0d26f --- /dev/null +++ b/web/src/lib/PlaceholderPanel.svelte @@ -0,0 +1,20 @@ + + +
+
+ {label} +

{title}

+

{detail}

+

Not available yet

+
+ +
diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts new file mode 100644 index 000000000..f5cc72fd2 --- /dev/null +++ b/web/src/lib/api.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ApiError, createToken, getBootstrap, listTokens, loginAdmin } from "./api"; + +describe("dashboard API client", () => { + it("normalizes bootstrap data from the control API", async () => { + const result = await getBootstrap(async () => + Response.json({ setupRequired: true, authenticated: false }), + ); + + expect(result).toEqual({ + ok: true, + value: { setupRequired: true, authenticated: false }, + }); + }); + + it("sends cookies and the double-submit CSRF token for authenticated mutations", async () => { + document.cookie = "executor_csrf=csrf_test_value; Path=/"; + let observedHeaders = new Headers(); + let observedCredentials: RequestCredentials | undefined; + const created = await createToken("Laptop", async (_input, init) => { + observedHeaders = new Headers(init?.headers); + observedCredentials = init?.credentials; + return Response.json( + { + id: "token-id", + name: "Laptop", + token: "exr_secret", + createdAt: 123, + }, + { status: 201 }, + ); + }); + + expect(observedHeaders.get("x-executor-csrf")).toBe("csrf_test_value"); + expect(observedHeaders.get("content-type")).toBe("application/json"); + expect(observedCredentials).toBe("same-origin"); + expect(created).toEqual({ + ok: true, + value: { + id: "token-id", + name: "Laptop", + token: "exr_secret", + createdAt: 123, + }, + }); + }); + + it("does not attach a stale CSRF token to login", async () => { + document.cookie = "executor_csrf=stale_value; Path=/"; + let observedHeaders = new Headers(); + await loginAdmin({ username: "admin", password: "password" }, async (_input, init) => { + observedHeaders = new Headers(init?.headers); + return Response.json({ username: "admin", csrfToken: "csrf_fresh" }); + }); + + expect(observedHeaders.has("x-executor-csrf")).toBe(false); + }); + + it("keeps token list responses masked", async () => { + const tokens = await listTokens(async () => + Response.json({ + tokens: [ + { + id: "token-id", + name: "Laptop", + maskedToken: "exr_abcd...wxyz", + createdAt: 123, + lastUsedAt: null, + revokedAt: null, + }, + ], + }), + ); + + expect(tokens).toEqual({ + ok: true, + value: [ + { + id: "token-id", + name: "Laptop", + maskedToken: "exr_abcd...wxyz", + createdAt: 123, + lastUsedAt: null, + revokedAt: null, + }, + ], + }); + }); + + it("preserves the stable server error envelope", async () => { + const failure = await getBootstrap(async () => + Response.json( + { + error: { + code: "unauthorized", + message: "An administrator session is required.", + requestId: "request-body", + }, + }, + { status: 401, headers: { "x-request-id": "request-header" } }, + ), + ); + + expect(failure).toEqual({ + ok: false, + error: new ApiError({ + code: "unauthorized", + displayMessage: "An administrator session is required.", + requestId: "request-body", + status: 401, + }), + }); + }); + + it("uses a response request ID when an error body is malformed", async () => { + const failure = await getBootstrap( + async () => + new Response("upstream exploded", { + status: 502, + headers: { "x-request-id": "request-header" }, + }), + ); + + expect(failure).toEqual({ + ok: false, + error: new ApiError({ + code: "http_502", + displayMessage: "Executor could not complete the request.", + requestId: "request-header", + status: 502, + }), + }); + }); + + it("reports successful payloads that drift from the Rust DTO", async () => { + const failure = await getBootstrap(async () => + Response.json( + { setupRequired: "yes", authenticated: false }, + { headers: { "x-request-id": "request-invalid" } }, + ), + ); + + expect(failure).toEqual({ + ok: false, + error: new ApiError({ + code: "invalid_response", + displayMessage: "Executor returned a response the dashboard could not understand.", + requestId: "request-invalid", + status: 502, + }), + }); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 000000000..41236be8e --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,254 @@ +import { Option, Schema } from "effect"; + +const BootstrapSchema = Schema.Struct({ + setupRequired: Schema.Boolean, + authenticated: Schema.Boolean, +}); + +const SessionSchema = Schema.Struct({ + username: Schema.String, + csrfToken: Schema.NullOr(Schema.String), +}); + +const TokenMetadataSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + maskedToken: Schema.String, + createdAt: Schema.Number, + lastUsedAt: Schema.NullOr(Schema.Number), + revokedAt: Schema.NullOr(Schema.Number), +}); + +const CreatedTokenSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + token: Schema.String, + createdAt: Schema.Number, +}); + +const TokenListSchema = Schema.Struct({ + tokens: Schema.Array(TokenMetadataSchema), +}); + +const ErrorEnvelopeSchema = Schema.Struct({ + error: Schema.Struct({ + code: Schema.String, + message: Schema.String, + requestId: Schema.String, + }), +}); + +export type Bootstrap = typeof BootstrapSchema.Type; +export type Session = typeof SessionSchema.Type; +export type TokenMetadata = typeof TokenMetadataSchema.Type; +export type CreatedToken = typeof CreatedTokenSchema.Type; + +export class ApiError extends Schema.TaggedErrorClass()("ApiError", { + code: Schema.String, + displayMessage: Schema.String, + requestId: Schema.NullOr(Schema.String), + status: Schema.Number, +}) {} + +export type ApiResult = + | { readonly ok: true; readonly value: Value } + | { readonly ok: false; readonly error: ApiError }; + +type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type ResponsePayload = { text: string; requestId: string | null }; + +const decodeBootstrap = Schema.decodeUnknownOption(Schema.fromJsonString(BootstrapSchema)); +const decodeSession = Schema.decodeUnknownOption(Schema.fromJsonString(SessionSchema)); +const decodeCreatedToken = Schema.decodeUnknownOption(Schema.fromJsonString(CreatedTokenSchema)); +const decodeTokenList = Schema.decodeUnknownOption(Schema.fromJsonString(TokenListSchema)); +const decodeErrorEnvelope = Schema.decodeUnknownOption(Schema.fromJsonString(ErrorEnvelopeSchema)); + +export async function getBootstrap(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/bootstrap", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeBootstrap); +} + +export async function setupAdmin( + input: { setupToken: string; username: string; password: string }, + fetcher: Fetcher = fetch, +) { + const response = await request( + "/api/v1/setup", + { method: "POST", body: JSON.stringify(input) }, + fetcher, + false, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSession); +} + +export async function loginAdmin( + input: { username: string; password: string }, + fetcher: Fetcher = fetch, +) { + const response = await request( + "/api/v1/session", + { method: "POST", body: JSON.stringify(input) }, + fetcher, + false, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSession); +} + +export async function getSession(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/session", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSession); +} + +export async function logoutAdmin(fetcher: Fetcher = fetch) { + const response = await request("/api/v1/session", { method: "DELETE" }, fetcher); + if (!response.ok) return response; + return { ok: true, value: undefined } as const; +} + +export async function listTokens(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/tokens", { signal }, fetcher); + if (!response.ok) return response; + + const decoded = decodeResponse(response.value, decodeTokenList); + if (!decoded.ok) return decoded; + return { ok: true, value: [...decoded.value.tokens] } as const; +} + +export async function createToken(name: string, fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request( + "/api/v1/tokens", + { method: "POST", body: JSON.stringify({ name }), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCreatedToken); +} + +export async function revokeToken(tokenId: string, fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request( + `/api/v1/tokens/${encodeURIComponent(tokenId)}`, + { method: "DELETE", signal }, + fetcher, + ); + if (!response.ok) return response; + return { ok: true, value: undefined } as const; +} + +async function request(path: string, init: RequestInit, fetcher: Fetcher, includeCsrf = true) { + if (!path.startsWith("/api/")) { + return failure( + "invalid_request_path", + "The dashboard only sends requests to this Executor instance.", + null, + 0, + ); + } + + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body !== undefined) headers.set("content-type", "application/json"); + if (includeCsrf && isUnsafeMethod(init.method)) { + const csrfToken = readCookie("executor_csrf"); + if (csrfToken !== null) headers.set("x-executor-csrf", csrfToken); + } + + const fetched = await fetcher(path, { + ...init, + headers, + credentials: "same-origin", + }).then( + (response) => ({ ok: true, response }) as const, + () => ({ ok: false }) as const, + ); + + if (!fetched.ok) { + return failure( + init.signal?.aborted ? "request_cancelled" : "network_error", + init.signal?.aborted + ? "The request was cancelled." + : "Executor could not be reached. Check that the local server is running.", + null, + 0, + ); + } + + const { response } = fetched; + const requestId = response.headers.get("x-request-id"); + const body = await response.text().then( + (text) => ({ ok: true, text }) as const, + () => ({ ok: false }) as const, + ); + if (!body.ok) { + return failure( + "invalid_response", + "Executor returned a response body the dashboard could not read.", + requestId, + 502, + ); + } + + if (!response.ok) return decodeError(body.text, requestId, response.status); + return { ok: true, value: { text: body.text, requestId } } as const; +} + +function decodeResponse( + response: ResponsePayload, + decoder: (text: string) => Option.Option, +): ApiResult { + const decoded = decoder(response.text); + if (Option.isNone(decoded)) { + return failure( + "invalid_response", + "Executor returned a response the dashboard could not understand.", + response.requestId, + 502, + ); + } + return { ok: true, value: decoded.value }; +} + +function decodeError(text: string, headerRequestId: string | null, status: number) { + const decoded = decodeErrorEnvelope(text); + if (Option.isNone(decoded)) { + return failure(`http_${status}`, fallbackStatusMessage(status), headerRequestId, status); + } + + return failure( + decoded.value.error.code, + decoded.value.error.message, + decoded.value.error.requestId || headerRequestId, + status, + ); +} + +function failure(code: string, displayMessage: string, requestId: string | null, status: number) { + return { + ok: false, + error: new ApiError({ code, displayMessage, requestId, status }), + } as const; +} + +function fallbackStatusMessage(status: number) { + if (status === 401) return "Your administrator session is no longer valid."; + if (status === 403) return "Executor rejected this request."; + if (status === 404) return "The requested Executor resource does not exist."; + if (status >= 500) return "Executor could not complete the request."; + return `Executor rejected the request with status ${status}.`; +} + +function isUnsafeMethod(method: string | undefined) { + return method !== undefined && !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase()); +} + +function readCookie(name: string) { + if (typeof document === "undefined") return null; + for (const part of document.cookie.split(";")) { + const [cookieName, ...valueParts] = part.trim().split("="); + if (cookieName === name) return valueParts.join("="); + } + return null; +} diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 000000000..7a5796457 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1,6 @@ + + Executor + + + + diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts new file mode 100644 index 000000000..005ae4abd --- /dev/null +++ b/web/src/lib/auth.svelte.ts @@ -0,0 +1,196 @@ +import { getContext, setContext } from "svelte"; +import { + getBootstrap, + getSession, + loginAdmin, + logoutAdmin, + setupAdmin, + type ApiError, +} from "$lib/api"; + +const authContext = Symbol("executor-auth"); + +export function createAuthState() { + let phase = $state<"loading" | "ready" | "error">("loading"); + let setupRequired = $state(false); + let authenticated = $state(false); + let username = $state(null); + let error = $state(null); + let notice = $state(null); + let refreshGeneration = 0; + let mutationGeneration = 0; + + async function refresh(signal?: AbortSignal) { + const mine = ++refreshGeneration; + phase = "loading"; + error = null; + + const bootstrap = await getBootstrap(undefined, signal); + if (mine !== refreshGeneration || signal?.aborted) return; + if (!bootstrap.ok) { + if (recoverFromApiError(bootstrap.error)) return; + error = bootstrap.error; + phase = "error"; + return; + } + + let nextUsername: string | null = null; + + if (bootstrap.value.authenticated) { + const session = await getSession(undefined, signal); + if (mine !== refreshGeneration || signal?.aborted) return; + if (!session.ok) { + if (recoverFromApiError(session.error)) return; + error = session.error; + phase = "error"; + return; + } + nextUsername = session.value.username; + } + + if (mine !== refreshGeneration || signal?.aborted) return; + setupRequired = bootstrap.value.setupRequired; + authenticated = bootstrap.value.authenticated; + username = nextUsername; + phase = "ready"; + } + + async function signIn(input: { username: string; password: string }) { + const mine = beginMutation(); + const session = await loginAdmin(input); + settleMutation(); + if (mine !== mutationGeneration) return session; + if (!session.ok) return session; + setupRequired = false; + authenticated = true; + username = session.value.username; + notice = null; + error = null; + phase = "ready"; + return session; + } + + async function completeSetup(input: { setupToken: string; username: string; password: string }) { + const mine = beginMutation(); + const setup = await setupAdmin(input); + if (mine !== mutationGeneration) { + settleMutation(); + return setup; + } + if (!setup.ok) { + settleMutation(); + return setup; + } + + const login = await loginAdmin({ username: input.username, password: input.password }); + settleMutation(); + if (mine !== mutationGeneration) return login; + if (!login.ok) { + setupRequired = false; + authenticated = false; + username = null; + notice = + "Your administrator was created, but automatic sign-in failed. Sign in with the credentials you just chose."; + error = null; + phase = "ready"; + return login; + } + + setupRequired = false; + authenticated = true; + username = login.value.username; + notice = null; + error = null; + phase = "ready"; + return login; + } + + async function signOut() { + const mine = beginMutation(); + const logout = await logoutAdmin(); + settleMutation(); + if (mine !== mutationGeneration) return logout; + if (!logout.ok) { + recoverFromApiError(logout.error); + return logout; + } + authenticated = false; + username = null; + notice = null; + return logout; + } + + function recoverFromApiError(apiError: ApiError) { + if (apiError.status !== 401 && apiError.code !== "invalid_csrf") return false; + + mutationGeneration += 1; + refreshGeneration += 1; + authenticated = false; + username = null; + error = null; + notice = + apiError.code === "invalid_csrf" + ? "Your security token is no longer valid. Sign in again to continue." + : "Your session expired. Sign in again to continue."; + phase = "ready"; + return true; + } + + function beginMutation() { + refreshGeneration += 1; + mutationGeneration += 1; + return mutationGeneration; + } + + function settleMutation() { + refreshGeneration += 1; + phase = "ready"; + } + + function clearNotice() { + notice = null; + } + + function dispose() { + refreshGeneration += 1; + mutationGeneration += 1; + } + + return { + get phase() { + return phase; + }, + get setupRequired() { + return setupRequired; + }, + get authenticated() { + return authenticated; + }, + get username() { + return username; + }, + get error() { + return error; + }, + get notice() { + return notice; + }, + refresh, + signIn, + completeSetup, + signOut, + recoverFromApiError, + clearNotice, + dispose, + }; +} + +export type AuthState = ReturnType; + +export function provideAuthState(auth: AuthState) { + setContext(authContext, auth); +} + +export function useAuthState() { + return getContext(authContext); +} diff --git a/web/src/lib/auth.test.ts b/web/src/lib/auth.test.ts new file mode 100644 index 000000000..67d2a4b88 --- /dev/null +++ b/web/src/lib/auth.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { ApiError } from "./api"; +import { createAuthState } from "./auth.svelte"; + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("dashboard auth state", () => { + it("hydrates an authenticated administrator session", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ setupRequired: false, authenticated: true })) + .mockResolvedValueOnce(Response.json({ username: "admin", csrfToken: null })); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.refresh(); + + expect(auth.phase).toBe("ready"); + expect(auth.setupRequired).toBe(false); + expect(auth.authenticated).toBe(true); + expect(auth.username).toBe("admin"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("claims first boot and immediately establishes the session", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ username: "admin", csrfToken: null }, { status: 201 })) + .mockResolvedValueOnce(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + const result = await auth.completeSetup({ + setupToken: "set_secret", + username: "admin", + password: "long-enough-password", + }); + + expect(result.ok).toBe(true); + expect(auth.setupRequired).toBe(false); + expect(auth.authenticated).toBe(true); + expect(auth.username).toBe("admin"); + expect(auth.notice).toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("does not let an older refresh overwrite a successful sign-in", async () => { + const bootstrap = deferred(); + const fetcher = vi.fn((input, init) => { + if (String(input) === "/api/v1/session" && init?.method === "POST") { + return Promise.resolve(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + } + return bootstrap.promise; + }); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + const refreshing = auth.refresh(); + const signedIn = await auth.signIn({ username: "admin", password: "password" }); + bootstrap.resolve(Response.json({ setupRequired: false, authenticated: false })); + await refreshing; + + expect(signedIn.ok).toBe(true); + expect(auth.phase).toBe("ready"); + expect(auth.authenticated).toBe(true); + expect(auth.username).toBe("admin"); + }); + + it("does not let an older refresh overwrite a completed first boot", async () => { + const bootstrap = deferred(); + const fetcher = vi.fn((input, init) => { + if (String(input) === "/api/v1/setup") { + return Promise.resolve( + Response.json({ username: "admin", csrfToken: null }, { status: 201 }), + ); + } + if (String(input) === "/api/v1/session" && init?.method === "POST") { + return Promise.resolve(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + } + return bootstrap.promise; + }); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + const refreshing = auth.refresh(); + const setup = await auth.completeSetup({ + setupToken: "set_secret", + username: "admin", + password: "long-enough-password", + }); + bootstrap.resolve(Response.json({ setupRequired: true, authenticated: false })); + await refreshing; + + expect(setup.ok).toBe(true); + expect(auth.setupRequired).toBe(false); + expect(auth.authenticated).toBe(true); + }); + + it("does not let an older refresh restore a completed sign-out", async () => { + const bootstrap = deferred(); + const fetcher = vi.fn((input, init) => { + if (String(input) === "/api/v1/session" && init?.method === "POST") { + return Promise.resolve(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + } + if (String(input) === "/api/v1/session" && init?.method === "DELETE") { + return Promise.resolve(new Response(null, { status: 204 })); + } + return bootstrap.promise; + }); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.signIn({ username: "admin", password: "password" }); + const refreshing = auth.refresh(); + const signedOut = await auth.signOut(); + bootstrap.resolve(Response.json({ setupRequired: false, authenticated: true })); + await refreshing; + + expect(signedOut.ok).toBe(true); + expect(auth.phase).toBe("ready"); + expect(auth.authenticated).toBe(false); + expect(auth.username).toBeNull(); + }); + + it("transitions to sign-in when an authenticated API reports an invalid CSRF token", async () => { + const fetcher = vi + .fn() + .mockResolvedValue(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.signIn({ username: "admin", password: "password" }); + const recovered = auth.recoverFromApiError( + new ApiError({ + code: "invalid_csrf", + displayMessage: "A valid CSRF token is required.", + requestId: "request-1", + status: 403, + }), + ); + + expect(recovered).toBe(true); + expect(auth.authenticated).toBe(false); + expect(auth.phase).toBe("ready"); + expect(auth.notice).toContain("Sign in again"); + }); + + it("recovers from a session that expires between bootstrap and session lookup", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ setupRequired: false, authenticated: true })) + .mockResolvedValueOnce( + Response.json( + { + error: { + code: "unauthorized", + message: "An administrator session is required.", + requestId: "request-expired", + }, + }, + { status: 401 }, + ), + ); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.refresh(); + + expect(auth.phase).toBe("ready"); + expect(auth.authenticated).toBe(false); + expect(auth.notice).toContain("session expired"); + }); +}); diff --git a/web/src/lib/clipboard.test.ts b/web/src/lib/clipboard.test.ts new file mode 100644 index 000000000..323f71185 --- /dev/null +++ b/web/src/lib/clipboard.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, expect, it, vi } from "@effect/vitest"; +import { copyText } from "./clipboard"; + +let clipboardDescriptor: PropertyDescriptor | undefined; +let execCommandDescriptor: PropertyDescriptor | undefined; + +beforeEach(() => { + clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + execCommandDescriptor = Object.getOwnPropertyDescriptor(document, "execCommand"); +}); + +afterEach(() => { + if (clipboardDescriptor === undefined) { + Reflect.deleteProperty(navigator, "clipboard"); + } else { + Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + } + + if (execCommandDescriptor === undefined) { + Reflect.deleteProperty(document, "execCommand"); + } else { + Object.defineProperty(document, "execCommand", execCommandDescriptor); + } + document.body.replaceChildren(); +}); + +it("uses the visible readonly field as a fallback and restores focus", async () => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.fn(() => true); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + + const copyButton = document.createElement("button"); + const field = document.createElement("input"); + field.readOnly = true; + document.body.append(copyButton, field); + copyButton.focus(); + + const copied = await copyText("exr_secret", field); + + expect(copied).toBe(true); + expect(field.value).toBe("exr_secret"); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(document.activeElement).toBe(copyButton); +}); diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts new file mode 100644 index 000000000..84e64cd2a --- /dev/null +++ b/web/src/lib/clipboard.ts @@ -0,0 +1,24 @@ +export async function copyText(text: string, fallbackField: HTMLInputElement) { + if (navigator.clipboard?.writeText) { + const copied = await navigator.clipboard.writeText(text).then( + () => true, + () => false, + ); + if (copied) return true; + } + + const previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + fallbackField.value = text; + fallbackField.focus(); + fallbackField.select(); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: legacy clipboard fallback must report failure and restore focus + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + previousFocus?.focus({ preventScroll: true }); + } +} diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 000000000..856f2b6c3 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/web/src/lib/navigation.test.ts b/web/src/lib/navigation.test.ts new file mode 100644 index 000000000..bf001f923 --- /dev/null +++ b/web/src/lib/navigation.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "@effect/vitest"; +import { consumeSetupToken, routeDestination, safeReturnTo } from "./navigation"; + +describe("dashboard navigation helpers", () => { + it("consumes a setup token without putting it in the replacement URL", () => { + let replacement = ""; + const token = consumeSetupToken( + new URL("http://executor.local/setup?from=terminal#token=set_secret"), + (nextUrl) => { + replacement = nextUrl; + }, + ); + + expect(token).toBe("set_secret"); + expect(replacement).toBe("/setup?from=terminal"); + expect(replacement).not.toContain("set_secret"); + }); + + it("accepts only protected same-origin return paths", () => { + expect(safeReturnTo("/tokens?created=true", "http://executor.local")).toBe( + "/tokens?created=true", + ); + expect(safeReturnTo("/tokens#active", "http://executor.local")).toBe("/tokens#active"); + expect(safeReturnTo("/tokens#token=set_secret", "http://executor.local")).toBe("/tokens"); + expect(safeReturnTo("//attacker.example/tokens", "http://executor.local")).toBeNull(); + expect(safeReturnTo("https://attacker.example/tokens", "http://executor.local")).toBeNull(); + expect(safeReturnTo("/login", "http://executor.local")).toBeNull(); + }); + + it("retains an internal destination across login", () => { + const destination = routeDestination({ + pathname: "/tokens", + search: "?filter=active", + hash: "#latest", + origin: "http://executor.local", + setupRequired: false, + authenticated: false, + }); + + expect(destination).toBe("/login?returnTo=%2Ftokens%3Ffilter%3Dactive%23latest"); + }); + + it("omits a setup token fragment from the login return path", () => { + const destination = routeDestination({ + pathname: "/tokens", + search: "", + hash: "#token=set_secret", + origin: "http://executor.local", + setupRequired: false, + authenticated: false, + }); + + expect(destination).toBe("/login?returnTo=%2Ftokens"); + }); +}); diff --git a/web/src/lib/navigation.ts b/web/src/lib/navigation.ts new file mode 100644 index 000000000..902569978 --- /dev/null +++ b/web/src/lib/navigation.ts @@ -0,0 +1,58 @@ +const protectedRoutes = ["/sources", "/tools", "/approvals", "/logs", "/tokens"]; + +export function isProtectedPath(pathname: string) { + return protectedRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`)); +} + +export function safeReturnTo(value: string | null, origin: string) { + if (value === null || !value.startsWith("/") || value.startsWith("//")) return null; + + if (!URL.canParse(value, origin)) return null; + const url = new URL(value, origin); + + if (url.origin !== origin || !isProtectedPath(url.pathname)) return null; + return `${url.pathname}${url.search}${safeReturnHash(url.hash)}`; +} + +export function safeReturnHash(hash: string) { + if (hash === "") return ""; + return new URLSearchParams(hash.slice(1)).has("token") ? "" : hash; +} + +export function consumeSetupToken(url: URL, replace: (nextUrl: string) => void) { + if (url.hash === "") return null; + + const token = new URLSearchParams(url.hash.slice(1)).get("token"); + replace(`${url.pathname}${url.search}`); + return token === null || token === "" ? null : token; +} + +export function routeDestination(input: { + pathname: string; + search: string; + hash: string; + origin: string; + setupRequired: boolean; + authenticated: boolean; +}) { + const { pathname, search, hash, origin, setupRequired, authenticated } = input; + + if (setupRequired) return pathname === "/setup" ? null : "/setup"; + + if (authenticated) { + if (pathname === "/login") { + const returnTo = new URLSearchParams(search).get("returnTo"); + return safeReturnTo(returnTo, origin) ?? "/sources"; + } + if (pathname === "/" || pathname === "/setup") return "/sources"; + if (!isProtectedPath(pathname)) return "/sources"; + return null; + } + + if (pathname === "/login") return null; + if (isProtectedPath(pathname)) { + const returnTo = `${pathname}${search}${safeReturnHash(hash)}`; + return `/login?returnTo=${encodeURIComponent(returnTo)}`; + } + return "/login"; +} diff --git a/web/src/lib/token-page-state.test.ts b/web/src/lib/token-page-state.test.ts new file mode 100644 index 000000000..5a5979677 --- /dev/null +++ b/web/src/lib/token-page-state.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "@effect/vitest"; +import { canCreateToken, shouldBlockTokenExit, tokenListView } from "./token-page-state"; + +describe("API token page state", () => { + it("distinguishes an unavailable initial load from an empty successful load", () => { + expect(tokenListView({ loading: false, hasLoaded: false, hasError: true, tokenCount: 0 })).toBe( + "unavailable", + ); + expect(tokenListView({ loading: false, hasLoaded: true, hasError: false, tokenCount: 0 })).toBe( + "empty", + ); + }); + + it("labels retained data as stale after a refresh failure", () => { + expect(tokenListView({ loading: false, hasLoaded: true, hasError: true, tokenCount: 2 })).toBe( + "stale", + ); + }); + + it("blocks another token creation until the revealed secret is explicitly saved", () => { + expect(canCreateToken({ name: "Laptop", creating: false, hasUnsavedToken: true })).toBe(false); + expect(canCreateToken({ name: "Laptop", creating: false, hasUnsavedToken: false })).toBe(true); + }); + + it("guards navigation throughout creation and one-time reveal", () => { + expect(shouldBlockTokenExit({ creating: true, hasUnsavedToken: false })).toBe(true); + expect(shouldBlockTokenExit({ creating: false, hasUnsavedToken: true })).toBe(true); + expect(shouldBlockTokenExit({ creating: false, hasUnsavedToken: false })).toBe(false); + }); +}); diff --git a/web/src/lib/token-page-state.ts b/web/src/lib/token-page-state.ts new file mode 100644 index 000000000..ce0a3c935 --- /dev/null +++ b/web/src/lib/token-page-state.ts @@ -0,0 +1,25 @@ +export type TokenListView = "loading" | "unavailable" | "empty" | "ready" | "stale"; + +export function tokenListView(input: { + loading: boolean; + hasLoaded: boolean; + hasError: boolean; + tokenCount: number; +}) { + if (!input.hasLoaded && input.loading) return "loading"; + if (!input.hasLoaded) return input.hasError ? "unavailable" : "loading"; + if (input.hasError) return "stale"; + return input.tokenCount === 0 ? "empty" : "ready"; +} + +export function canCreateToken(input: { + name: string; + creating: boolean; + hasUnsavedToken: boolean; +}) { + return input.name.trim() !== "" && !input.creating && !input.hasUnsavedToken; +} + +export function shouldBlockTokenExit(input: { creating: boolean; hasUnsavedToken: boolean }) { + return input.creating || input.hasUnsavedToken; +} diff --git a/web/src/lib/token-reveal-focus.test.ts b/web/src/lib/token-reveal-focus.test.ts new file mode 100644 index 000000000..9e0a073a0 --- /dev/null +++ b/web/src/lib/token-reveal-focus.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { focusRevealedToken } from "./token-reveal-focus"; + +const token = { + id: "token-1", + name: "Laptop agent", + token: "exr_public_secret", + createdAt: 1_750_000_000, +}; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("one-time token focus", () => { + it("focuses and selects the revealed token after its input renders", async () => { + const previous = document.createElement("button"); + const field = document.createElement("input"); + field.readOnly = true; + field.value = token.token; + document.body.append(previous, field); + previous.focus(); + + await focusRevealedToken({ + token, + currentToken: () => token, + field: () => field, + isCurrentLifetime: () => true, + }); + + expect(document.activeElement).toBe(field); + expect(field.selectionStart).toBe(0); + expect(field.selectionEnd).toBe(token.token.length); + }); + + it("leaves focus alone for a stale lifetime or replaced token", async () => { + const previous = document.createElement("button"); + const field = document.createElement("input"); + document.body.append(previous, field); + previous.focus(); + + await focusRevealedToken({ + token, + currentToken: () => token, + field: () => field, + isCurrentLifetime: () => false, + }); + await focusRevealedToken({ + token, + currentToken: () => null, + field: () => field, + isCurrentLifetime: () => true, + }); + + expect(document.activeElement).toBe(previous); + }); +}); diff --git a/web/src/lib/token-reveal-focus.ts b/web/src/lib/token-reveal-focus.ts new file mode 100644 index 000000000..887b05b55 --- /dev/null +++ b/web/src/lib/token-reveal-focus.ts @@ -0,0 +1,17 @@ +import { tick } from "svelte"; +import type { CreatedToken } from "./api"; + +export async function focusRevealedToken(input: { + token: CreatedToken; + currentToken: () => CreatedToken | null; + field: () => HTMLInputElement | undefined; + isCurrentLifetime: () => boolean; +}) { + await tick(); + if (!input.isCurrentLifetime() || input.currentToken() !== input.token) return; + + const field = input.field(); + if (field === undefined) return; + field.focus(); + field.select(); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 000000000..67f0edfec --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,82 @@ + + + + Executor + + + + +{#if auth.phase === "error" && auth.error !== null} +
+
+ +
+ + +
+
+
+{:else if canRender} + {@render children()} +{:else} +
+
+ +

Checking your Executor session...

+
+
+{/if} diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 000000000..a3d15781a --- /dev/null +++ b/web/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 000000000..09e479b91 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,9 @@ +
+
+ +
+

Executor

+

Opening your local gateway...

+
+
+
diff --git a/web/src/routes/approvals/+page.svelte b/web/src/routes/approvals/+page.svelte new file mode 100644 index 000000000..a1605331f --- /dev/null +++ b/web/src/routes/approvals/+page.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 000000000..260c401fb --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,73 @@ + + + + Sign in | Executor + + +
+
+
+ +
+

Administrator

+

Open your gateway.

+
+
+

The dashboard session controls configuration. API tokens only reach tools.

+ + {#if auth.notice !== null} +
+ {auth.notice} + +
+ {/if} + +
+ + + {#if error !== null}{/if} + + +
+
diff --git a/web/src/routes/logs/+page.svelte b/web/src/routes/logs/+page.svelte new file mode 100644 index 000000000..ea6fc5f08 --- /dev/null +++ b/web/src/routes/logs/+page.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/web/src/routes/setup/+page.svelte b/web/src/routes/setup/+page.svelte new file mode 100644 index 000000000..afac011fc --- /dev/null +++ b/web/src/routes/setup/+page.svelte @@ -0,0 +1,122 @@ + + + + Set up Executor + + +
+
+
+ +
+

First boot

+

Make this instance yours.

+
+
+

+ Create the only administrator account. The one-time setup secret is removed from browser + history as soon as this page opens. +

+ + {#if fragmentRead && setupToken === null} + + {/if} + +
+ + + + {#if passwordConfirmation !== "" && !passwordsMatch} + + {/if} + {#if error !== null}{/if} + + +
+
diff --git a/web/src/routes/setup/page.test.ts b/web/src/routes/setup/page.test.ts new file mode 100644 index 000000000..ee564c132 --- /dev/null +++ b/web/src/routes/setup/page.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "@effect/vitest"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import Page from "./+page.svelte"; + +it("removes the first-boot token from browser history without rendering it", async () => { + history.replaceState({}, "", "/setup#token=set_test_secret"); + render(Page); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(screen.getByRole("heading", { name: "Make this instance yours." })).toBeDefined(); + expect(document.body.textContent).not.toContain("set_test_secret"); +}); diff --git a/web/src/routes/sources/+page.svelte b/web/src/routes/sources/+page.svelte new file mode 100644 index 000000000..8e958ee30 --- /dev/null +++ b/web/src/routes/sources/+page.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/web/src/routes/tokens/+page.svelte b/web/src/routes/tokens/+page.svelte new file mode 100644 index 000000000..63be8004b --- /dev/null +++ b/web/src/routes/tokens/+page.svelte @@ -0,0 +1,369 @@ + + + + + +
+
+

New credential

+

Create an API token

+

+ Name it for the device or agent that will use it. All tokens share the enabled tool set. +

+
+ + + {#if hasUnsavedToken} + Save the revealed token before creating another. + {/if} + {#if mutationError !== null}{/if} + + +
+ + {#if revealed !== null} +
+

Shown once

+

Copy this token now.

+ + event.currentTarget.select()} + /> +

+ Executor stores only a keyed digest. This secret cannot be recovered later. +

+
+ + +
+

+ {copyMessage} +

+ {#if navigationBlocked} +

+ Save the token and choose “I saved it” before leaving this page. +

+ {/if} +
+ {/if} +
+ + {#if listView === "stale" && listError !== null} +
+ Showing the last successfully loaded token list. + +
+ {/if} + +
+
+
+

Gateway access

+

Issued tokens

+
+ +
+ + {#if listView === "loading"} +

Loading tokens...

+ {:else if listView === "unavailable" && listError !== null} +
+ Token list unavailable +

We could not load your API tokens. Try again to see which tokens have been issued.

+ +
+ {:else if listView === "empty"} +

No API tokens have been issued.

+ {:else if listView === "stale" && tokens.length === 0} +

The last successful load contained no API tokens.

+ {:else} +
+ + + + + + + + + + + + + {#each tokens as token (token.id)} + + + + + + + + {/each} + +
API tokens issued by this Executor instance
NameTokenLast usedStatusAction
+ {token.name}Created {displayDate(token.createdAt)} + {token.maskedToken}{displayDate(token.lastUsedAt)} + + {token.revokedAt === null ? "Active" : "Revoked"} + + + +
+
+ {/if} +
+ + { + if (revoking) event.preventDefault(); + }} + onclose={() => { + if (!revoking) pendingRevoke = null; + }} + > +

Revoke credential

+

Stop using {pendingRevoke?.name ?? "this token"}?

+

Calls using this token will fail immediately. This action cannot be undone.

+ {#if revokeError !== null}{/if} +
+ + +
+
+
diff --git a/web/src/routes/tools/+page.svelte b/web/src/routes/tools/+page.svelte new file mode 100644 index 000000000..60ffc48b4 --- /dev/null +++ b/web/src/routes/tools/+page.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 000000000..f02b0234a --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,838 @@ +:root { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + color: #e9edf5; + background: #080b12; + font-synthesis: none; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at 80% -20%, rgba(88, 101, 242, 0.2), transparent 35%), #080b12; +} + +button, +input { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +a { + color: inherit; +} + +button { + min-height: 2.55rem; + border: 1px solid #30384a; + border-radius: 10px; + padding: 0.68rem 0.95rem; + color: #dfe5f2; + background: #151b28; + cursor: pointer; +} + +:where(a, button, input):focus-visible { + outline: 3px solid rgba(145, 160, 255, 0.72); + outline-offset: 3px; +} + +button:hover:not(:disabled) { + border-color: #66728b; + background: #1b2333; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.64; +} + +button.primary { + border-color: #7181ff; + color: #07101d; + background: #91a0ff; + font-weight: 750; +} + +button.primary:hover:not(:disabled) { + border-color: #aab4ff; + background: #aab4ff; +} + +.skip-link { + position: fixed; + z-index: 100; + top: 0.75rem; + left: 0.75rem; + transform: translateY(-180%); + border-radius: 8px; + padding: 0.65rem 0.9rem; + color: #07101d; + background: #c2caff; + font-weight: 750; + text-decoration: none; +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +.app-frame { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + height: 100vh; + padding: 1.25rem; + border-right: 1px solid #202738; + background: rgba(10, 14, 22, 0.9); + backdrop-filter: blur(18px); +} + +.brand { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 2rem; + text-decoration: none; +} + +.brand-mark { + display: inline-grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + border: 1px solid #7181ff; + border-radius: 11px; + color: #b6c0ff; + background: #1c2443; + box-shadow: inset 0 0 20px rgba(113, 129, 255, 0.15); + font-size: 0.75rem; + font-weight: 850; + letter-spacing: 0.08em; +} + +.brand strong, +.brand small, +.instance-card strong, +.instance-card small, +td small { + display: block; +} + +.brand small, +.instance-card small, +td small { + margin-top: 0.2rem; + color: #9da8bc; + font-size: 0.72rem; +} + +nav { + display: grid; + gap: 0.35rem; +} + +nav a { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.72rem 0.8rem; + border-radius: 9px; + color: #b2bccf; + text-decoration: none; + font-size: 0.9rem; +} + +.nav-marker { + color: #929eb4; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.68rem; +} + +.nav-label { + min-width: 0; +} + +.nav-count { + min-width: 1.65rem; + margin-left: auto; + padding: 0.14rem 0.35rem; + border: 1px solid #343d51; + border-radius: 999px; + color: #aab5c8; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.62rem; + text-align: center; +} + +nav a:hover, +nav a.active { + color: #f1f3f9; + background: #161c29; +} + +nav a.active { + box-shadow: inset 3px 0 0 #91a0ff; + font-weight: 750; +} + +nav a.active .nav-marker { + color: #91a0ff; +} + +.instance-card { + display: flex; + gap: 0.65rem; + align-items: center; + margin-top: auto; + padding: 0.85rem; + border: 1px solid #242c3d; + border-radius: 12px; + background: #10151f; + font-size: 0.8rem; +} + +.status-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: #55d6a5; + box-shadow: 0 0 10px rgba(85, 214, 165, 0.6); +} + +main { + min-width: 0; +} + +.page-header { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: flex-start; + padding: 2.4rem clamp(1.25rem, 4vw, 4rem) 1.8rem; + border-bottom: 1px solid #202738; +} + +.page-header h1, +.auth-card h1 { + margin: 0.15rem 0 0.55rem; + color: #f7f8fb; + font-size: clamp(1.7rem, 3vw, 2.5rem); + line-height: 1.05; + letter-spacing: -0.04em; +} + +.page-header > div > p:last-child, +.lede, +.surface > p { + max-width: 650px; + margin: 0; + color: #a8b2c4; + line-height: 1.65; +} + +.eyebrow { + margin: 0; + color: #a6b1ff; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.admin-actions { + display: flex; + gap: 0.75rem; + align-items: center; + color: #a3aec1; + font-size: 0.78rem; + white-space: nowrap; +} + +.admin-actions strong { + color: #cbd2df; + font-weight: 650; +} + +.shell-notice { + padding: 0 clamp(1.25rem, 4vw, 4rem); +} + +.page-content { + display: grid; + gap: 1.25rem; + padding: 2rem clamp(1.25rem, 4vw, 4rem) 4rem; +} + +.surface { + border: 1px solid #242c3d; + border-radius: 16px; + background: linear-gradient(145deg, rgba(20, 26, 39, 0.95), rgba(13, 17, 26, 0.95)); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.16); +} + +.placeholder-grid, +.token-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr); + gap: 1.25rem; +} + +.empty-state, +.scope-card, +.token-form, +.reveal-card { + padding: clamp(1.35rem, 4vw, 2.2rem); +} + +.empty-state h2, +.scope-card h3, +.surface h2 { + margin: 1rem 0 0.65rem; + color: #eef1f7; + letter-spacing: -0.025em; +} + +.number-chip { + display: inline-block; + padding: 0.25rem 0.4rem; + border: 1px solid #3b4560; + border-radius: 6px; + color: #9dabff; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.7rem; +} + +.availability { + display: inline-block; + margin-top: 1.4rem !important; + border: 1px solid #4a556d; + border-radius: 999px; + padding: 0.35rem 0.65rem; + color: #c1cada !important; + background: #171e2b; + font-size: 0.76rem; + font-weight: 700; +} + +.centered-page { + display: grid; + min-height: 100vh; + padding: 2rem 1.25rem; + place-items: center; +} + +.auth-card { + width: min(100%, 510px); + padding: clamp(1.4rem, 5vw, 2.6rem); + border: 1px solid #273044; + border-radius: 20px; + background: rgba(14, 19, 29, 0.94); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.35); +} + +.auth-card.compact { + display: flex; + gap: 1rem; + align-items: center; + max-width: 430px; +} + +.auth-heading { + display: flex; + gap: 1rem; + align-items: center; +} + +.auth-card form, +.token-form form { + display: grid; + gap: 1rem; + margin-top: 1.6rem; +} + +label { + display: grid; + gap: 0.45rem; + color: #cbd2df; + font-size: 0.82rem; + font-weight: 650; +} + +label small { + color: #9ba6ba; + font-weight: 400; +} + +input { + width: 100%; + border: 1px solid #66728b; + border-radius: 10px; + outline: none; + padding: 0.78rem 0.85rem; + color: #edf0f6; + background: #0b1019; +} + +input:focus { + border-color: #7181ff; + box-shadow: 0 0 0 3px rgba(113, 129, 255, 0.13); +} + +input:disabled { + color: #a4aec0; + background: #141923; +} + +input[readonly] { + cursor: text; +} + +.notice { + margin-top: 1rem; + padding: 0.8rem 0.9rem; + border: 1px solid #3a4356; + border-radius: 10px; + color: #cbd2df; + background: #161c28; + font-size: 0.82rem; + line-height: 1.5; +} + +.notice.warning { + border-color: #635739; + color: #e5d7ad; + background: #211d14; +} + +.notice.error { + border-color: #6a3c48; + color: #f4bdc8; + background: #24141a; +} + +.notice strong, +.notice small { + display: block; +} + +.notice small { + margin-top: 0.4rem; + color: inherit; + opacity: 0.8; +} + +.text-button { + min-height: auto; + margin: 0.55rem 0 0; + border: 0; + padding: 0; + color: inherit; + background: transparent; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +.field-error { + margin: -0.45rem 0 0; + color: #f4bdc8; + font-size: 0.78rem; +} + +code { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.reveal-card { + border-color: #5a5794; + background: linear-gradient(145deg, #202344, #14182b); +} + +.reveal-card code { + display: block; + overflow-wrap: anywhere; + margin: 1rem 0; + padding: 0.8rem; + border-radius: 8px; + color: #d9deff; + background: #0d1020; + font-size: 0.76rem; +} + +.token-secret { + margin-top: 0.45rem; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 0.78rem; +} + +.copy-status { + min-height: 1.35rem; + margin-top: 0.8rem !important; + color: #c4ccda !important; + font-size: 0.78rem; +} + +.reveal-card button { + margin-top: 1rem; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + align-items: center; +} + +.button-row button { + margin-top: 0; +} + +.table-card { + overflow: hidden; +} + +.stale-notice { + border: 1px solid #75683f; + border-radius: 14px; + padding: 1rem; + color: #f0e2b7; + background: #252015; +} + +.stale-notice > .notice { + margin-top: 0.75rem; +} + +.table-unavailable { + display: grid; + gap: 0.65rem; + padding: 1.4rem; + color: #c5cddc; +} + +.table-unavailable p { + margin: 0; + color: #a6b0c2; +} + +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.4rem; + border-bottom: 1px solid #252d3e; +} + +.section-heading h2 { + margin: 0.25rem 0 0; +} + +.table-scroll { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +caption { + padding: 0.85rem 1.2rem; + color: #a8b3c7; + text-align: left; + font-size: 0.76rem; +} + +th, +td { + padding: 0.95rem 1.2rem; + border-bottom: 1px solid #202738; + text-align: left; + white-space: nowrap; +} + +th { + color: #9da9bd; + font-size: 0.67rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +td { + color: #c1c9d8; +} + +.table-empty { + margin: 0; + padding: 2rem; + color: #a2aec2; + text-align: center; +} + +.status-pill { + display: inline-block; + padding: 0.25rem 0.5rem; + border: 1px solid #315b50; + border-radius: 999px; + color: #75d5b3; + background: #11231f; + font-size: 0.68rem; +} + +.status-pill.revoked { + border-color: #493f48; + color: #b8adb7; + background: #1c181d; +} + +.danger-link { + min-height: 2rem; + border-color: #583945; + padding: 0.3rem 0.55rem; + color: #e29aaa; + background: #21151a; +} + +.danger-button { + border-color: #875061; + color: #ffe5eb; + background: #6a3041; +} + +.confirm-dialog { + width: min(calc(100% - 2rem), 480px); + border: 1px solid #364056; + border-radius: 18px; + padding: 1.5rem; + color: #e9edf5; + background: #121824; + box-shadow: 0 30px 100px rgba(0, 0, 0, 0.58); +} + +.confirm-dialog::backdrop { + background: rgba(2, 5, 10, 0.76); + backdrop-filter: blur(4px); +} + +.confirm-dialog h2 { + margin: 0.65rem 0; + color: #f2f4f9; +} + +.confirm-dialog > p:not(.eyebrow) { + color: #9ba5b9; + line-height: 1.6; +} + +.dialog-actions { + justify-content: flex-end; + margin-top: 1.4rem; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 820px) { + .app-frame { + display: block; + } + + .sidebar { + position: static; + display: block; + height: auto; + border-right: 0; + border-bottom: 1px solid #202738; + } + + .brand, + .instance-card { + display: none; + } + + nav { + display: flex; + overflow-x: auto; + } + + nav a { + flex: 0 0 auto; + } + + .nav-count { + margin-left: 0; + } + + .placeholder-grid, + .token-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .section-heading { + align-items: flex-start; + flex-wrap: wrap; + gap: 0.85rem; + } + + .table-scroll { + overflow: visible; + } + + table, + tbody, + tr, + td { + display: block; + width: 100%; + } + + thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + tbody { + padding: 0.65rem; + } + + tr { + margin-bottom: 0.65rem; + border: 1px solid #2c3548; + border-radius: 12px; + padding: 0.55rem 0; + background: #101620; + } + + td { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + border: 0; + padding: 0.48rem 0.75rem; + white-space: normal; + overflow-wrap: anywhere; + text-align: right; + } + + td::before { + flex: 0 0 5.5rem; + color: #9da9bd; + content: attr(data-label); + font-size: 0.67rem; + font-weight: 750; + letter-spacing: 0.06em; + text-align: left; + text-transform: uppercase; + } + + td code { + min-width: 0; + overflow-wrap: anywhere; + } +} + +@media (max-width: 520px) { + .sidebar { + padding: 0.75rem; + } + + nav { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: visible; + } + + nav a { + min-width: 0; + gap: 0.45rem; + padding: 0.65rem; + } + + nav a:last-child { + grid-column: 1 / -1; + } + + .nav-marker { + display: none; + } + + .nav-label { + overflow-wrap: anywhere; + } + + .nav-count { + min-width: 1.45rem; + } + + .page-header { + display: block; + padding-inline: 0.85rem; + } + + .admin-actions { + align-items: flex-start; + flex-direction: column; + margin-top: 1rem; + white-space: normal; + } + + .auth-heading { + align-items: flex-start; + } + + .page-content { + padding-inline: 0.75rem; + } + + .centered-page { + padding-inline: 0.75rem; + } + + .empty-state, + .scope-card, + .token-form, + .reveal-card { + padding: 1.1rem; + } +} diff --git a/web/static/robots.txt b/web/static/robots.txt new file mode 100644 index 000000000..b6dd6670c --- /dev/null +++ b/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 000000000..850d095d6 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,11 @@ +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ fallback: "index.html", strict: true }), + }, +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..43447105a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..92c070f63 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { svelteTesting } from "@testing-library/svelte/vite"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [sveltekit(), svelteTesting()], + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, +}); From faa403c4a56eadfcb7decdb4953e475a548cc1f2 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Tue, 23 Jun 2026 21:19:16 -0700 Subject: [PATCH 02/24] feat: add source and tool catalog --- docs/architecture.md | 85 + migrations/20260623010000_catalog.sql | 175 ++ src/api.rs | 341 ++- src/api/catalog.rs | 823 +++++++ src/api/request_logs.rs | 284 +++ src/catalog/mod.rs | 564 +++++ src/catalog/search.rs | 383 ++++ src/catalog/store.rs | 2853 +++++++++++++++++++++++++ src/database.rs | 7 + src/lib.rs | 15 +- tests/catalog.rs | 2582 ++++++++++++++++++++++ tests/control_plane.rs | 273 ++- 12 files changed, 8362 insertions(+), 23 deletions(-) create mode 100644 migrations/20260623010000_catalog.sql create mode 100644 src/api/catalog.rs create mode 100644 src/api/request_logs.rs create mode 100644 src/catalog/mod.rs create mode 100644 src/catalog/search.rs create mode 100644 src/catalog/store.rs create mode 100644 tests/catalog.rs diff --git a/docs/architecture.md b/docs/architecture.md index 1c8597fbf..4b20425d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,3 +83,88 @@ and associated data binds ciphertext to its purpose and record identity. The current TypeScript implementation stays in place until the replacement has parity. Moving it early would obscure behavior that still serves as the reference contract. + +## Source and tool catalog contract + +The catalog is global to the single-user instance. Every API token sees the +same source and tool set. A source has one immutable ID, one collision-safe +slug, one non-secret configuration object, and at most one encrypted credential +payload. Source kinds are limited to `openapi`, `graphql`, `mcp_http`, and +`mcp_stdio`. Credential payloads carry a schema version and are encrypted with +record-bound associated data, so ciphertext copied between sources cannot be +decrypted. + +Tools retain an immutable ID and upstream stable key separately from their +display name and callable name. The full callable path is +`tools..`. Sandboxed TypeScript omits the leading +`tools.` because that is the proxy root. Local names are normalized and +collision suffixes are allocated in stable-key order. Existing and tombstoned +names stay reserved, which keeps paths stable when upstream discovery reorders, +removes, or restores a tool. The source roots `tools`, `search`, `describe`, and +`executor` are reserved for the sandbox and built-in catalog helpers. + +A source may expose at most 100,000 active tools and retain at most 25,000 +tombstoned tool identities, for a hard 125,000-row history ceiling. Refreshes +that would exceed either ceiling fail atomically with `catalog_too_large`. +Executor never silently deletes tombstones because they carry stable IDs, +callable names, and administrator overrides. Intentionally discarding that +history requires deleting and recreating the source. Administrator tool lists +apply filters, counts, ordering, and pagination in SQLite so reads remain +memory-bounded at the history ceiling. + +Gateway search uses bounded word, trigram, and encoded short-gram indexes. SQL +first excludes tombstones and effectively disabled tools, then caps the +deduplicated candidate set at 4,096 before the reference lexical scorer runs. +This preserves token-prefix, substring, and reverse-prefix recall without +hydrating the complete historical table. + +Each tool has an intrinsic mode. A source and a tool may each add an override. +The effective mode is always derived in this order: + +1. Tool override +2. Source override +3. Tool intrinsic mode + +The visible modes are `enabled`, `ask`, and `disabled`. Disabled tools remain +visible in the administrator catalog, but gateway search and describe omit +them. A direct invocation lookup returns `tool_disabled`, which prevents a +caller with an old path from bypassing the catalog. Ask tools remain +discoverable and carry approval-required metadata. + +Protocol discovery and schema normalization happen before a catalog write. +The staged snapshot records the source and credential revisions it used. The +commit rechecks both revisions, serializes catalog writers, replaces source +artifacts, upserts tools by `(source_id, stable_key)`, tombstones missing tools, +and advances the source and global catalog revisions in one transaction. A +failed stage or stale revision leaves the last known good catalog untouched. +Network work never occurs inside this transaction. + +Administrator catalog reads require a session cookie. Catalog mutations also +require the matching Origin, CSRF cookie, and CSRF header. Gateway discovery, +description, and invocation lookup accept API tokens only. The initial routes +are: + +- `GET /api/v1/sources` and `GET /api/v1/sources/{id}` +- `DELETE /api/v1/sources/{id}` +- `PATCH /api/v1/sources/{id}/mode` +- `GET /api/v1/tools` and `GET /api/v1/tools/{id}` +- `PATCH /api/v1/tools/{id}/mode` and `PATCH /api/v1/tools/modes` +- `GET /api/v1/request-logs` and `GET /api/v1/request-logs/{id}` +- `POST /api/v1/gateway/tools/search` +- `POST /api/v1/gateway/tools/describe` +- `POST /api/v1/gateway/tools/lookup` + +Request logs store metadata only: request ID, nullable actor token ID, surface, +nullable source and tool IDs, an immutable callable-path snapshot, outcome, +stable error code, duration, nullable approval ID, and timestamp. They never +store request headers, credentials, arguments, or results. Source and tool +deletion clears their foreign keys while retaining the history and path +snapshot. + +Audit history is bounded to the most recently inserted 10,000 events for a +single-user instance. Each event's serialized metadata is capped at 64 KiB. +Insertion and oldest-insertion compaction happen in the same catalog +transaction, so a committed mutation retains its actor, correlation ID, target +snapshots, and metadata while never leaving an over-cap audit table. Retention +uses SQLite insertion order rather than wall-clock timestamps, so clock +corrections cannot discard the audit for a newly committed mutation. diff --git a/migrations/20260623010000_catalog.sql b/migrations/20260623010000_catalog.sql new file mode 100644 index 000000000..bb9741ff0 --- /dev/null +++ b/migrations/20260623010000_catalog.sql @@ -0,0 +1,175 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE catalog_state ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +INSERT INTO catalog_state (id, revision, created_at, updated_at) +VALUES (1, 0, unixepoch(), unixepoch()); + +CREATE TABLE sources ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) BETWEEN 1 AND 128), + kind TEXT NOT NULL CHECK (kind IN ('openapi', 'graphql', 'mcp_http', 'mcp_stdio')), + slug TEXT NOT NULL UNIQUE + CHECK (length(slug) BETWEEN 1 AND 63) + CHECK (substr(slug, 1, 1) BETWEEN 'a' AND 'z') + CHECK (slug NOT GLOB '*[^a-z0-9_]*') + CHECK (slug NOT IN ('tools', 'search', 'describe', 'executor')), + search_short_grams TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 200), + description TEXT CHECK (description IS NULL OR length(description) <= 2000), + configuration_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(configuration_json)) + CHECK (json_type(configuration_json) = 'object'), + mode_override TEXT CHECK (mode_override IS NULL OR mode_override IN ('enabled', 'ask', 'disabled')), + health_status TEXT NOT NULL DEFAULT 'unknown' + CHECK (health_status IN ('unknown', 'healthy', 'error')), + health_error_code TEXT + CHECK (health_error_code IS NULL OR length(health_error_code) BETWEEN 1 AND 128), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + catalog_revision INTEGER NOT NULL DEFAULT 0 CHECK (catalog_revision >= 0), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_refreshed_at INTEGER, + CHECK ( + (health_status = 'error' AND health_error_code IS NOT NULL) + OR (health_status <> 'error' AND health_error_code IS NULL) + ) +) STRICT; + +CREATE INDEX sources_kind_slug_idx ON sources(kind, slug); + +CREATE TABLE source_credentials ( + source_id TEXT PRIMARY KEY NOT NULL + REFERENCES sources(id) ON DELETE CASCADE, + schema_version INTEGER NOT NULL CHECK (schema_version > 0), + payload_ciphertext BLOB NOT NULL CHECK (length(payload_ciphertext) > 0), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE source_artifacts ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) BETWEEN 1 AND 128), + source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE, + artifact_kind TEXT NOT NULL + CHECK (artifact_kind IN ('openapi_document', 'graphql_schema', 'mcp_capabilities', 'metadata')), + stable_key TEXT NOT NULL CHECK (length(stable_key) BETWEEN 1 AND 512), + content_json TEXT NOT NULL CHECK (json_valid(content_json)), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (source_id, artifact_kind, stable_key) +) STRICT; + +CREATE TABLE tools ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) BETWEEN 1 AND 128), + source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE, + stable_key TEXT NOT NULL CHECK (length(stable_key) BETWEEN 1 AND 1024), + local_name TEXT NOT NULL + CHECK (length(local_name) BETWEEN 1 AND 128) + CHECK (substr(local_name, 1, 1) BETWEEN 'a' AND 'z') + CHECK (local_name NOT GLOB '*[^a-z0-9_]*'), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 300), + description TEXT CHECK (description IS NULL OR length(description) <= 4000), + search_description TEXT NOT NULL DEFAULT '', + search_short_grams TEXT NOT NULL DEFAULT '', + input_schema_json TEXT NOT NULL CHECK (json_valid(input_schema_json)), + output_schema_json TEXT CHECK (output_schema_json IS NULL OR json_valid(output_schema_json)), + input_typescript TEXT CHECK (input_typescript IS NULL OR length(input_typescript) <= 100000), + output_typescript TEXT CHECK (output_typescript IS NULL OR length(output_typescript) <= 100000), + typescript_definitions_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(typescript_definitions_json)) + CHECK (json_type(typescript_definitions_json) = 'object'), + intrinsic_mode TEXT NOT NULL CHECK (intrinsic_mode IN ('enabled', 'ask', 'disabled')), + mode_override TEXT CHECK (mode_override IS NULL OR mode_override IN ('enabled', 'ask', 'disabled')), + present INTEGER NOT NULL DEFAULT 1 CHECK (present IN (0, 1)), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + tombstoned_at INTEGER, + UNIQUE (source_id, stable_key), + UNIQUE (source_id, local_name), + CHECK ( + (present = 1 AND tombstoned_at IS NULL) + OR (present = 0 AND tombstoned_at IS NOT NULL) + ) +) STRICT; + +CREATE INDEX tools_source_presence_name_idx + ON tools(source_id, present, local_name); +CREATE INDEX tools_presence_mode_idx + ON tools(present, intrinsic_mode, mode_override); + +CREATE VIRTUAL TABLE tool_search USING fts5( + source_id UNINDEXED, + tool_id UNINDEXED, + source_slug, + local_name, + description, + sandbox_path, + tokenize = 'unicode61' +); + +CREATE VIRTUAL TABLE tool_search_trigram USING fts5( + source_id UNINDEXED, + tool_id UNINDEXED, + source_slug, + local_name, + description, + sandbox_path, + tokenize = 'trigram' +); + +CREATE VIRTUAL TABLE tool_search_short USING fts5( + source_id UNINDEXED, + tool_id UNINDEXED, + grams, + tokenize = 'unicode61' +); + +CREATE TABLE request_logs ( + request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128), + actor_api_token_id TEXT REFERENCES api_tokens(id) ON DELETE SET NULL, + surface TEXT NOT NULL CHECK (surface IN ('admin', 'gateway', 'cli', 'mcp')), + source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + tool_id TEXT REFERENCES tools(id) ON DELETE SET NULL, + path_snapshot TEXT CHECK (path_snapshot IS NULL OR length(path_snapshot) BETWEEN 1 AND 512), + outcome TEXT NOT NULL CHECK (outcome IN ('succeeded', 'failed', 'pending_approval', 'denied')), + error_code TEXT CHECK (error_code IS NULL OR length(error_code) BETWEEN 1 AND 128), + duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0), + approval_id TEXT CHECK (approval_id IS NULL OR length(approval_id) BETWEEN 1 AND 128), + created_at INTEGER NOT NULL, + CHECK ((source_id IS NULL AND tool_id IS NULL) OR path_snapshot IS NOT NULL) +) STRICT; + +CREATE INDEX request_logs_created_cursor_idx + ON request_logs(created_at DESC, request_id DESC); +CREATE INDEX request_logs_actor_created_idx + ON request_logs(actor_api_token_id, created_at DESC); +CREATE INDEX request_logs_tool_created_idx + ON request_logs(tool_id, created_at DESC); + +CREATE TABLE audit_events ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) BETWEEN 1 AND 128), + request_id TEXT CHECK (request_id IS NULL OR length(request_id) BETWEEN 1 AND 128), + actor_admin_id INTEGER REFERENCES admins(id) ON DELETE SET NULL, + action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 128), + source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + tool_id TEXT REFERENCES tools(id) ON DELETE SET NULL, + target_path_snapshot TEXT + CHECK (target_path_snapshot IS NULL OR length(target_path_snapshot) BETWEEN 1 AND 512), + metadata_json TEXT NOT NULL DEFAULT '{}' + CHECK (json_valid(metadata_json)) + CHECK (json_type(metadata_json) = 'object'), + created_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX audit_events_created_idx + ON audit_events(created_at DESC, id DESC); +CREATE INDEX audit_events_source_created_idx + ON audit_events(source_id, created_at DESC); diff --git a/src/api.rs b/src/api.rs index 8c4f44d3f..32a03fe3e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -21,16 +21,23 @@ use uuid::Uuid; use crate::{ AppConfig, + catalog::CatalogStore, crypto::{generate_secret, hash_password, verify_password}, database::{Database, SETUP_TOKEN_TTL_SECONDS}, unix_timestamp, }; +mod catalog; +mod request_logs; + +use request_logs::GatewayRequestLogSink; + const SESSION_COOKIE: &str = "executor_session"; const CSRF_COOKIE: &str = "executor_csrf"; const CSRF_HEADER: &str = "x-executor-csrf"; const MAX_API_BODY_BYTES: usize = 16 * 1024; const PASSWORD_HASH_CONCURRENCY: usize = 2; +const GATEWAY_SEARCH_CONCURRENCY: usize = 1; const MAX_USERNAME_CHARACTERS: usize = 64; const MAX_USERNAME_BYTES: usize = 256; const MIN_PASSWORD_CHARACTERS: usize = 12; @@ -44,16 +51,23 @@ const MAX_LOGIN_RATE_LIMIT_CLIENTS: usize = 4096; const MAX_FORWARDED_FOR_HOPS: usize = 64; const MAX_FORWARDED_FOR_BYTES: usize = 4 * 1024; const X_FORWARDED_FOR: &str = "x-forwarded-for"; +const TOKEN_LAST_USED_WRITE_INTERVAL_SECONDS: i64 = 60; +const TOKEN_LAST_USED_WRITE_INTERVAL: Duration = Duration::from_secs(60); +const MAX_TOKEN_LAST_USED_ATTEMPTS: usize = 4096; #[derive(Clone)] struct AppState { database: Database, + catalog: CatalogStore, + request_logs: GatewayRequestLogSink, origin: Arc, session_ttl_seconds: i64, secure_cookies: bool, password_hash_slots: Arc, + gateway_search_slots: Arc, dummy_password_hash: Arc, login_rate_limiter: Arc, + token_last_used_tracker: Arc, trusted_proxies: Arc<[IpNet]>, } @@ -71,6 +85,11 @@ struct LoginRateLimiter { attempts: Mutex>>, } +struct TokenLastUsedTracker { + attempts: Mutex>, + write_slot: Arc, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ErrorEnvelope { @@ -236,6 +255,79 @@ impl LoginRateLimiter { } } +impl TokenLastUsedTracker { + fn new() -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + write_slot: Arc::new(Semaphore::new(1)), + } + } + + fn schedule( + self: &Arc, + database: Database, + token_id: String, + used_at: i64, + request_id: String, + ) { + let attempted_at = Instant::now(); + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + attempts.retain(|_, previous_attempt| { + attempted_at.saturating_duration_since(*previous_attempt) + < TOKEN_LAST_USED_WRITE_INTERVAL + }); + if attempts.contains_key(&token_id) { + return; + } + let Ok(permit) = self.write_slot.clone().try_acquire_owned() else { + return; + }; + if attempts.len() >= MAX_TOKEN_LAST_USED_ATTEMPTS + && let Some(oldest) = attempts + .iter() + .min_by_key(|(_, attempted_at)| *attempted_at) + .map(|(token_id, _)| token_id.clone()) + { + attempts.remove(&oldest); + } + attempts.insert(token_id.clone(), attempted_at); + drop(attempts); + + let tracker = Arc::clone(self); + tokio::spawn(async move { + let _permit = permit; + let database_guard = database; + let result = sqlx::query( + "UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND revoked_at IS NULL \ + AND (last_used_at IS NULL OR last_used_at <= ?)", + ) + .bind(used_at) + .bind(&token_id) + .bind(used_at - TOKEN_LAST_USED_WRITE_INTERVAL_SECONDS) + .execute(&database_guard.pool) + .await; + if let Err(error) = result { + tracker.clear_failed_attempt(&token_id, attempted_at); + tracing::warn!(request_id, error = %error, "API token last-used update failed"); + } + drop(database_guard); + }); + } + + fn clear_failed_attempt(&self, token_id: &str, attempted_at: Instant) { + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if attempts.get(token_id) == Some(&attempted_at) { + attempts.remove(token_id); + } + } +} + #[derive(Serialize)] struct HealthResponse { status: &'static str, @@ -306,7 +398,13 @@ struct GatewayIdentityResponse { token_name: String, } +struct GatewayIdentity { + token_id: String, + token_name: String, +} + struct AdminSession { + id: i64, username: String, session_digest: Vec, csrf_digest: Vec, @@ -314,17 +412,23 @@ struct AdminSession { pub(crate) fn router( database: Database, + catalog: CatalogStore, config: &AppConfig, dummy_password_hash: String, ) -> Router { + let request_logs = GatewayRequestLogSink::new(database.clone(), catalog.clone()); let state = AppState { database, + catalog, + request_logs, origin: Arc::from(config.public_origin()), session_ttl_seconds: config.session_ttl_seconds, secure_cookies: config.origin.scheme() == "https", password_hash_slots: Arc::new(Semaphore::new(PASSWORD_HASH_CONCURRENCY)), + gateway_search_slots: Arc::new(Semaphore::new(GATEWAY_SEARCH_CONCURRENCY)), dummy_password_hash: Arc::from(dummy_password_hash), login_rate_limiter: Arc::new(LoginRateLimiter::new()), + token_last_used_tracker: Arc::new(TokenLastUsedTracker::new()), trusted_proxies: config.trusted_proxies.clone(), }; let middleware_state = state.clone(); @@ -337,7 +441,9 @@ pub(crate) fn router( .route("/api/v1/tokens", get(list_tokens).post(create_token)) .route("/api/v1/tokens/{id}", delete(revoke_token)) .route("/api/v1/gateway/whoami", get(gateway_whoami)) + .merge(catalog::router()) .fallback(not_found) + .method_not_allowed_fallback(method_not_allowed) .with_state(state) .layer(DefaultBodyLimit::max(MAX_API_BODY_BYTES)) .layer(middleware::from_fn_with_state( @@ -833,26 +939,47 @@ async fn gateway_whoami( State(state): State, headers: HeaderMap, ) -> Result, ApiError> { - let token = bearer_token(&headers).ok_or_else(|| { - ApiError::unauthorized(&request_id, "A valid Executor API token is required.") + let identity = require_gateway_token(&request_id, &state, &headers).await?; + Ok(Json(GatewayIdentityResponse { + token_id: identity.token_id, + token_name: identity.token_name, + })) +} + +async fn require_gateway_token( + request_id: &RequestId, + state: &AppState, + headers: &HeaderMap, +) -> Result { + let token = bearer_token(headers).ok_or_else(|| { + ApiError::unauthorized(request_id, "A valid Executor API token is required.") })?; let digest = state.database.keyring.digest("api-token", token.as_bytes()); - let identity = sqlx::query_as::<_, (String, String)>( - "UPDATE api_tokens SET last_used_at = ? \ - WHERE token_digest = ? AND revoked_at IS NULL RETURNING id, name", + let identity = sqlx::query_as::<_, (String, String, Option)>( + "SELECT id, name, last_used_at FROM api_tokens \ + WHERE token_digest = ? AND revoked_at IS NULL", ) - .bind(unix_timestamp()) .bind(digest.to_vec()) .fetch_optional(&state.database.pool) .await - .map_err(|error| ApiError::internal_logged(&request_id, error))? - .ok_or_else(|| { - ApiError::unauthorized(&request_id, "A valid Executor API token is required.") - })?; - Ok(Json(GatewayIdentityResponse { + .map_err(|error| ApiError::internal_logged(request_id, error))? + .ok_or_else(|| ApiError::unauthorized(request_id, "A valid Executor API token is required."))?; + let now = unix_timestamp(); + if identity + .2 + .is_none_or(|last_used_at| last_used_at <= now - TOKEN_LAST_USED_WRITE_INTERVAL_SECONDS) + { + state.token_last_used_tracker.schedule( + state.database.clone(), + identity.0.clone(), + now, + request_id.0.clone(), + ); + } + Ok(GatewayIdentity { token_id: identity.0, token_name: identity.1, - })) + }) } async fn not_found(Extension(request_id): Extension) -> ApiError { @@ -864,6 +991,15 @@ async fn not_found(Extension(request_id): Extension) -> ApiError { ) } +async fn method_not_allowed(Extension(request_id): Extension) -> ApiError { + ApiError::new( + &request_id, + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + "The request method is not allowed for this resource.", + ) +} + fn parse_json( request_id: &RequestId, payload: Result, JsonRejection>, @@ -981,8 +1117,8 @@ async fn optional_admin_session( .database .keyring .digest("admin-session", token.as_bytes()); - sqlx::query_as::<_, (String, Vec, Vec)>( - "SELECT admins.username, admin_sessions.session_digest, admin_sessions.csrf_digest \ + sqlx::query_as::<_, (i64, String, Vec, Vec)>( + "SELECT admins.id, admins.username, admin_sessions.session_digest, admin_sessions.csrf_digest \ FROM admin_sessions JOIN admins ON admins.id = admin_sessions.admin_id \ WHERE admin_sessions.session_digest = ? AND admin_sessions.expires_at > ?", ) @@ -991,7 +1127,8 @@ async fn optional_admin_session( .fetch_optional(&state.database.pool) .await .map(|row| { - row.map(|(username, session_digest, csrf_digest)| AdminSession { + row.map(|(id, username, session_digest, csrf_digest)| AdminSession { + id, username, session_digest, csrf_digest, @@ -1088,3 +1225,177 @@ fn csrf_cookie(state: &AppState, token: &str) -> String { fn clear_cookie(name: &str) -> String { format!("{name}=; Path=/; SameSite=Lax; Max-Age=0") } + +#[cfg(test)] +mod tests { + use std::{ + sync::Arc, + time::{Duration, Instant}, + }; + + use tempfile::TempDir; + use tokio::time::timeout; + + use super::TokenLastUsedTracker; + use crate::{ + AppConfig, + database::{Database, DatabaseError, OpenedDatabase}, + }; + + async fn open_database() -> (TempDir, AppConfig, Database) { + let directory = tempfile::tempdir().expect("temporary data directory"); + let config = AppConfig::new(directory.path().into()); + let OpenedDatabase { database, .. } = + Database::open(&config).await.expect("test database opens"); + (directory, config, database) + } + + async fn insert_token(database: &Database, token_id: &str) { + sqlx::query( + "INSERT INTO api_tokens \ + (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(token_id) + .bind(token_id) + .bind(format!("digest-{token_id}").into_bytes()) + .bind("exr_test") + .bind("test") + .bind(1_i64) + .execute(&database.pool) + .await + .expect("test API token is inserted"); + } + + async fn wait_for_token_write(tracker: &Arc) { + let permit = timeout( + Duration::from_secs(2), + tracker.write_slot.clone().acquire_owned(), + ) + .await + .expect("token last-used write completes") + .expect("token last-used write semaphore remains open"); + drop(permit); + } + + #[tokio::test] + async fn failed_token_last_used_write_clears_only_its_matching_attempt_and_retries() { + let (_directory, _config, database) = open_database().await; + insert_token(&database, "retry-token").await; + sqlx::query( + "CREATE TRIGGER reject_token_last_used \ + BEFORE UPDATE OF last_used_at ON api_tokens \ + BEGIN SELECT RAISE(FAIL, 'forced token last-used failure'); END", + ) + .execute(&database.pool) + .await + .expect("failure trigger is installed"); + + let tracker = Arc::new(TokenLastUsedTracker::new()); + let unrelated_attempt = Instant::now(); + tracker + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert("unrelated-token".to_owned(), unrelated_attempt); + + let replaced_attempt = Instant::now(); + let replacement_attempt = replaced_attempt + Duration::from_secs(1); + tracker + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert("replacement-token".to_owned(), replacement_attempt); + tracker.clear_failed_attempt("replacement-token", replaced_attempt); + + tracker.schedule( + database.clone(), + "retry-token".to_owned(), + 1_750_000_000, + "failed-request".to_owned(), + ); + wait_for_token_write(&tracker).await; + + { + let attempts = tracker + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(!attempts.contains_key("retry-token")); + assert_eq!(attempts.get("unrelated-token"), Some(&unrelated_attempt)); + assert_eq!( + attempts.get("replacement-token"), + Some(&replacement_attempt) + ); + } + + sqlx::query("DROP TRIGGER reject_token_last_used") + .execute(&database.pool) + .await + .expect("failure trigger is removed"); + tracker.schedule( + database.clone(), + "retry-token".to_owned(), + 1_750_000_001, + "retry-request".to_owned(), + ); + wait_for_token_write(&tracker).await; + + let last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE id = 'retry-token'", + ) + .fetch_one(&database.pool) + .await + .expect("last-used timestamp is readable"); + assert_eq!(last_used, Some(1_750_000_001)); + } + + #[tokio::test] + async fn token_last_used_background_write_retains_the_instance_lock() { + let (_directory, config, database) = open_database().await; + insert_token(&database, "guarded-token").await; + let mut writer = database + .pool + .acquire() + .await + .expect("test writer connection is acquired"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .expect("test writer holds the SQLite write lock"); + + let tracker = Arc::new(TokenLastUsedTracker::new()); + tracker.schedule( + database.clone(), + "guarded-token".to_owned(), + 1_750_000_000, + "guarded-request".to_owned(), + ); + drop(database); + + let second_open = Database::open(&config).await; + assert!(matches!(second_open, Err(DatabaseError::AlreadyRunning(_)))); + + sqlx::query("COMMIT") + .execute(&mut *writer) + .await + .expect("test writer releases the SQLite write lock"); + drop(writer); + wait_for_token_write(&tracker).await; + + let reopened = timeout(Duration::from_secs(2), async { + loop { + match Database::open(&config).await { + Ok(opened) => break opened.database, + Err(DatabaseError::AlreadyRunning(_)) => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => panic!("database should reopen after the write: {error}"), + } + } + }) + .await + .expect("background write releases its instance-lock guard"); + reopened.pool.close().await; + } +} diff --git a/src/api/catalog.rs b/src/api/catalog.rs new file mode 100644 index 000000000..027e54849 --- /dev/null +++ b/src/api/catalog.rs @@ -0,0 +1,823 @@ +use axum::{ + Json, Router, + extract::{ + Extension, Path, Query, State, + rejection::{PathRejection, QueryRejection}, + }, + http::{HeaderMap, StatusCode}, + routing::{get, patch, post}, +}; +use serde::{Deserialize, Serialize}; +use std::{future::Future, sync::Arc, time::Instant}; +use tokio::sync::Semaphore; + +use super::{ + ApiError, AppState, RequestId, parse_json, require_admin, require_admin_mutation, + require_gateway_token, +}; +use crate::{ + catalog::{ + AuditContext, CatalogError, ListToolsFilter, NewRequestLog, RequestOutcome, RequestSurface, + ToolMode, + }, + unix_timestamp, +}; + +const MAX_QUERY_CHARACTERS: usize = 256; + +pub(super) fn router() -> Router { + Router::new() + .route("/api/v1/sources", get(list_sources)) + .route( + "/api/v1/sources/{id}", + get(source_detail).delete(delete_source), + ) + .route("/api/v1/sources/{id}/mode", patch(set_source_mode)) + .route("/api/v1/tools", get(list_tools)) + .route("/api/v1/tools/modes", patch(bulk_set_tool_modes)) + .route("/api/v1/tools/{id}", get(tool_detail)) + .route("/api/v1/tools/{id}/mode", patch(set_tool_mode)) + .route("/api/v1/request-logs", get(list_request_logs)) + .route("/api/v1/request-logs/{id}", get(request_log_detail)) + .route("/api/v1/gateway/tools/search", post(gateway_search)) + .route("/api/v1/gateway/tools/describe", post(gateway_describe)) + .route("/api/v1/gateway/tools/lookup", post(gateway_lookup)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SourceListResponse { + sources: Vec, + catalog_revision: i64, +} + +async fn list_sources( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let (sources, catalog_revision) = state + .catalog + .list_sources_snapshot() + .await + .map_err(|error| catalog_error(&request_id, error))?; + Ok(Json(SourceListResponse { + sources, + catalog_revision, + })) +} + +async fn source_detail( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + path: Result, PathRejection>, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let Path(source_id) = parse_path(&request_id, path)?; + state + .catalog + .source(&source_id) + .await + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +async fn delete_source( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + path: Result, PathRejection>, +) -> Result { + let admin = require_admin_mutation(&request_id, &state, &headers).await?; + let Path(source_id) = parse_path(&request_id, path)?; + state + .catalog + .delete_source(&source_id, AuditContext::admin(&request_id.0, admin.id)) + .await + .map_err(|error| catalog_error(&request_id, error))?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SetModeRequest { + mode: serde_json::Value, + expected_revision: i64, +} + +async fn set_source_mode( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + path: Result, PathRejection>, + payload: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let admin = require_admin_mutation(&request_id, &state, &headers).await?; + let Path(source_id) = parse_path(&request_id, path)?; + let Json(payload) = parse_json(&request_id, payload)?; + let mode = parse_mode(&request_id, payload.mode)?; + state + .catalog + .set_source_mode( + &source_id, + mode, + payload.expected_revision, + AuditContext::admin(&request_id.0, admin.id), + ) + .await + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListToolsQuery { + query: Option, + source_id: Option, + mode: Option, + include_tombstoned: Option, + limit: Option, + offset: Option, +} + +async fn list_tools( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let Query(query) = parse_query(&request_id, query)?; + validate_query(&request_id, query.query.as_deref())?; + state + .catalog + .list_tools(ListToolsFilter { + query: query.query, + source_id: query.source_id, + effective_mode: query.mode, + include_tombstoned: query.include_tombstoned.unwrap_or(false), + limit: query.limit.unwrap_or(50), + offset: query.offset.unwrap_or(0), + }) + .await + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +async fn tool_detail( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + path: Result, PathRejection>, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let Path(tool_id) = parse_path(&request_id, path)?; + state + .catalog + .tool(&tool_id) + .await + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +async fn set_tool_mode( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + path: Result, PathRejection>, + payload: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let admin = require_admin_mutation(&request_id, &state, &headers).await?; + let Path(tool_id) = parse_path(&request_id, path)?; + let Json(payload) = parse_json(&request_id, payload)?; + let mode = parse_mode(&request_id, payload.mode)?; + state + .catalog + .set_tool_mode( + &tool_id, + mode, + payload.expected_revision, + AuditContext::admin(&request_id.0, admin.id), + ) + .await + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct BulkModeRequest { + selection: BulkModeSelection, + mode: serde_json::Value, +} + +#[derive(Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +enum BulkModeSelection { + Source { + source_id: String, + expected_source_revision: i64, + }, + ToolIds { + tool_ids: Vec, + expected_catalog_revision: i64, + }, +} + +async fn bulk_set_tool_modes( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + payload: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let admin = require_admin_mutation(&request_id, &state, &headers).await?; + let Json(payload) = parse_json(&request_id, payload)?; + let mode = parse_mode(&request_id, payload.mode)?; + let result = match payload.selection { + BulkModeSelection::Source { + source_id, + expected_source_revision, + } => { + state + .catalog + .bulk_set_source_tool_modes( + &source_id, + mode, + expected_source_revision, + AuditContext::admin(&request_id.0, admin.id), + ) + .await + } + BulkModeSelection::ToolIds { + tool_ids, + expected_catalog_revision, + } => { + state + .catalog + .bulk_set_tool_modes( + &tool_ids, + mode, + expected_catalog_revision, + AuditContext::admin(&request_id.0, admin.id), + ) + .await + } + } + .map_err(|error| catalog_error(&request_id, error))?; + Ok(Json(result)) +} + +#[derive(Default, Deserialize)] +struct RequestLogQuery { + cursor: Option, + limit: Option, +} + +async fn list_request_logs( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let Query(query) = parse_query(&request_id, query)?; + state + .catalog + .list_request_logs(query.cursor.as_deref(), query.limit.unwrap_or(50)) + .await + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +async fn request_log_detail( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + path: Result, PathRejection>, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let Path(log_id) = parse_path(&request_id, path)?; + state + .catalog + .request_log(&log_id) + .await + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +#[derive(Deserialize)] +struct SearchRequest { + query: String, + namespace: Option, + limit: Option, + offset: Option, +} + +async fn gateway_search( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + payload: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let identity = require_gateway_token(&request_id, &state, &headers).await?; + let Json(payload) = parse_json(&request_id, payload)?; + validate_query(&request_id, Some(&payload.query))?; + let started = Instant::now(); + let catalog = state.catalog.clone(); + let query = payload.query; + let namespace = payload.namespace; + let limit = payload.limit.unwrap_or(12); + let offset = payload.offset.unwrap_or(0); + let result = + match with_gateway_search_slot(&request_id, &state.gateway_search_slots, async move { + catalog + .search_tools(&query, namespace.as_deref(), limit, offset) + .await + }) + .await + { + Ok(result) => result, + Err(error) => { + record_gateway_busy(&state, &request_id, &identity.token_id, started); + return Err(error); + } + }; + record_gateway_result( + &state, + &request_id, + &identity.token_id, + "tools.search", + None, + None, + started, + &result, + ); + result + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +async fn with_gateway_search_slot( + request_id: &RequestId, + slots: &Arc, + search: impl Future + Send + 'static, +) -> Result +where + T: Send + 'static, +{ + let permit = slots.clone().try_acquire_owned().map_err(|_| { + ApiError::new( + request_id, + StatusCode::TOO_MANY_REQUESTS, + "search_busy", + "Tool search is busy. Try again shortly.", + ) + .with_retry_after(1) + })?; + let search = tokio::spawn(async move { + let result = search.await; + drop(permit); + result + }); + Ok(search + .await + .expect("the gateway search task must run to completion")) +} + +fn record_gateway_busy(state: &AppState, request_id: &RequestId, token_id: &str, started: Instant) { + let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + state.request_logs.try_record(NewRequestLog { + request_id: request_id.0.clone(), + actor_api_token_id: Some(token_id.to_owned()), + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Failed, + error_code: Some("search_busy".to_owned()), + duration_ms, + approval_id: None, + created_at: unix_timestamp(), + }); +} + +#[derive(Deserialize)] +struct PathRequest { + path: String, +} + +async fn gateway_describe( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + payload: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let identity = require_gateway_token(&request_id, &state, &headers).await?; + let Json(payload) = parse_json(&request_id, payload)?; + validate_path(&request_id, &payload.path)?; + let started = Instant::now(); + let result = state.catalog.describe_tool(&payload.path).await; + record_gateway_result( + &state, + &request_id, + &identity.token_id, + &callable_snapshot(&payload.path), + None, + None, + started, + &result, + ); + result + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +async fn gateway_lookup( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + payload: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let identity = require_gateway_token(&request_id, &state, &headers).await?; + let Json(payload) = parse_json(&request_id, payload)?; + validate_path(&request_id, &payload.path)?; + let started = Instant::now(); + let result = state.catalog.guard_invocation(&payload.path).await; + let source_id = result.as_ref().ok().map(|lookup| lookup.source_id.as_str()); + let tool_id = result.as_ref().ok().map(|lookup| lookup.tool_id.as_str()); + let path = result + .as_ref() + .ok() + .map(|lookup| lookup.callable_path.clone()) + .unwrap_or_else(|| callable_snapshot(&payload.path)); + record_gateway_result( + &state, + &request_id, + &identity.token_id, + &path, + source_id, + tool_id, + started, + &result, + ); + result + .map(Json) + .map_err(|error| catalog_error(&request_id, error)) +} + +#[allow(clippy::too_many_arguments)] +fn record_gateway_result( + state: &AppState, + request_id: &RequestId, + token_id: &str, + path_snapshot: &str, + source_id: Option<&str>, + tool_id: Option<&str>, + started: Instant, + result: &Result, +) { + let (outcome, error_code) = match result { + Ok(_) => (RequestOutcome::Succeeded, None), + Err(CatalogError::ToolDisabled { .. }) => (RequestOutcome::Denied, Some("tool_disabled")), + Err(error) => (RequestOutcome::Failed, Some(catalog_error_code(error))), + }; + let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + state.request_logs.try_record(NewRequestLog { + request_id: request_id.0.clone(), + actor_api_token_id: Some(token_id.to_owned()), + surface: RequestSurface::Gateway, + source_id: source_id.map(str::to_owned), + tool_id: tool_id.map(str::to_owned), + path_snapshot: Some(path_snapshot.to_owned()), + outcome, + error_code: error_code.map(str::to_owned), + duration_ms, + approval_id: None, + created_at: unix_timestamp(), + }); +} + +fn catalog_error_code(error: &CatalogError) -> &'static str { + match error { + CatalogError::Validation { code, .. } => code, + CatalogError::NotFound { entity: "source" } => "source_not_found", + CatalogError::NotFound { + entity: "request log", + } => "request_log_not_found", + CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => "tool_not_found", + CatalogError::ToolDisabled { .. } => "tool_disabled", + CatalogError::RevisionConflict { .. } => "revision_conflict", + CatalogError::Database(_) + | CatalogError::Crypto(_) + | CatalogError::Json(_) + | CatalogError::CorruptData(_) => "internal_error", + } +} + +fn callable_snapshot(path: &str) -> String { + if path.starts_with("tools.") { + path.to_owned() + } else { + format!("tools.{path}") + } +} + +fn parse_query( + request_id: &RequestId, + query: Result, QueryRejection>, +) -> Result, ApiError> { + query.map_err(|_| { + ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_query", + "The query parameters are invalid.", + ) + }) +} + +fn parse_path( + request_id: &RequestId, + path: Result, PathRejection>, +) -> Result, ApiError> { + path.map_err(|_| { + ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_path", + "The path parameters are invalid.", + ) + }) +} + +fn parse_mode( + request_id: &RequestId, + value: serde_json::Value, +) -> Result, ApiError> { + if value.is_null() { + return Ok(None); + } + serde_json::from_value(value).map(Some).map_err(|_| { + ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_tool_mode", + "Tool mode must be enabled, ask, disabled, or null.", + ) + }) +} + +fn validate_query(request_id: &RequestId, query: Option<&str>) -> Result<(), ApiError> { + if query.is_some_and(|query| query.chars().count() > MAX_QUERY_CHARACTERS) { + Err(ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_query", + "Search queries may contain at most 256 characters.", + )) + } else { + Ok(()) + } +} + +fn validate_path(request_id: &RequestId, path: &str) -> Result<(), ApiError> { + let sandbox_path = path.strip_prefix("tools.").unwrap_or(path); + let mut segments = sandbox_path.split('.'); + let valid = segments.next().is_some_and(valid_path_segment) + && segments.next().is_some_and(valid_path_segment) + && segments.next().is_none() + && callable_snapshot(path).len() <= 512; + if !valid { + Err(ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_tool_path", + "A valid tool path is required.", + )) + } else { + Ok(()) + } +} + +fn valid_path_segment(segment: &str) -> bool { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|character| character.is_ascii_lowercase()) + && characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }) +} + +fn catalog_error(request_id: &RequestId, error: CatalogError) -> ApiError { + match error { + CatalogError::Validation { code, message } => { + ApiError::new(request_id, StatusCode::BAD_REQUEST, code, message) + } + CatalogError::NotFound { entity: "source" } => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "source_not_found", + "The requested source does not exist.", + ), + CatalogError::NotFound { + entity: "request log", + } => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "request_log_not_found", + "The requested request log does not exist.", + ), + CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "tool_not_found", + "The requested tool does not exist.", + ), + CatalogError::ToolDisabled { .. } => ApiError::new( + request_id, + StatusCode::FORBIDDEN, + "tool_disabled", + "The requested tool is disabled.", + ), + CatalogError::RevisionConflict { .. } => ApiError::new( + request_id, + StatusCode::CONFLICT, + "revision_conflict", + "The catalog changed. Refresh and retry the update.", + ), + error => ApiError::internal_logged(request_id, error), + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, + }; + + use axum::{http::header, response::IntoResponse}; + use http_body_util::BodyExt; + use serde_json::Value; + use tokio::{ + sync::{Notify, Semaphore}, + time::timeout, + }; + + use super::{RequestId, with_gateway_search_slot}; + + #[tokio::test] + async fn broad_gateway_search_rejects_excess_without_waiting_and_then_recovers() { + let slots = Arc::new(Semaphore::new(1)); + let broad_started = Arc::new(Notify::new()); + let release_broad = Arc::new(Notify::new()); + let broad_slots = Arc::clone(&slots); + let broad_started_signal = Arc::clone(&broad_started); + let broad_release_signal = Arc::clone(&release_broad); + let broad_search = tokio::spawn(async move { + with_gateway_search_slot( + &RequestId("broad-search".to_owned()), + &broad_slots, + async move { + broad_started_signal.notify_one(); + broad_release_signal.notified().await; + "broad-result" + }, + ) + .await + }); + broad_started.notified().await; + + let excess_polled = Arc::new(AtomicBool::new(false)); + let excess_polled_inside = Arc::clone(&excess_polled); + let busy = timeout( + Duration::from_millis(100), + with_gateway_search_slot(&RequestId("excess-search".to_owned()), &slots, async move { + excess_polled_inside.store(true, Ordering::SeqCst); + "must-not-run" + }), + ) + .await + .expect("an excess search must fail without waiting") + .expect_err("the occupied global search slot must reject excess work"); + assert!(!excess_polled.load(Ordering::SeqCst)); + + let response = busy.into_response(); + assert_eq!(response.status(), axum::http::StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + response.headers().get(header::RETRY_AFTER), + Some(&axum::http::HeaderValue::from_static("1")) + ); + let body = response + .into_body() + .collect() + .await + .expect("busy response body should collect") + .to_bytes(); + let body: Value = serde_json::from_slice(&body).expect("busy response should contain JSON"); + assert_eq!(body["error"]["code"], "search_busy"); + assert_eq!(body["error"]["requestId"], "excess-search"); + + release_broad.notify_one(); + let broad_result = broad_search + .await + .expect("broad search task should not panic"); + let Ok(broad_result) = broad_result else { + panic!("broad search should own the slot"); + }; + assert_eq!(broad_result, "broad-result"); + let recovered = + with_gateway_search_slot(&RequestId("recovered-search".to_owned()), &slots, async { + "recovered-result" + }) + .await; + let Ok(recovered) = recovered else { + panic!("the global search slot should recover after completion"); + }; + assert_eq!(recovered, "recovered-result"); + } + + #[tokio::test] + async fn aborted_search_waiter_keeps_slot_until_underlying_search_completes() { + let slots = Arc::new(Semaphore::new(1)); + let search_started = Arc::new(Notify::new()); + let release_search = Arc::new(Notify::new()); + let search_completed = Arc::new(Notify::new()); + let waiter_slots = Arc::clone(&slots); + let started_signal = Arc::clone(&search_started); + let release_signal = Arc::clone(&release_search); + let completed_signal = Arc::clone(&search_completed); + let waiter = tokio::spawn(async move { + with_gateway_search_slot( + &RequestId("cancelled-search".to_owned()), + &waiter_slots, + async move { + started_signal.notify_one(); + release_signal.notified().await; + completed_signal.notify_one(); + "cancelled-waiter-result" + }, + ) + .await + }); + search_started.notified().await; + + waiter.abort(); + let waiter_error = match waiter.await { + Err(error) => error, + Ok(_) => panic!("the request-side waiter should be cancelled"), + }; + assert!(waiter_error.is_cancelled()); + + let excess_polled = Arc::new(AtomicBool::new(false)); + let excess_polled_inside = Arc::clone(&excess_polled); + let busy = with_gateway_search_slot( + &RequestId("cancelled-search-excess".to_owned()), + &slots, + async move { + excess_polled_inside.store(true, Ordering::SeqCst); + "must-not-run" + }, + ) + .await + .expect_err("the detached underlying search must continue owning the slot"); + assert!(!excess_polled.load(Ordering::SeqCst)); + assert_eq!( + busy.into_response().status(), + axum::http::StatusCode::TOO_MANY_REQUESTS + ); + + release_search.notify_one(); + timeout(Duration::from_secs(1), search_completed.notified()) + .await + .expect("the detached underlying search should finish"); + timeout(Duration::from_secs(1), async { + while slots.available_permits() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("the slot should be released after underlying completion"); + + let recovered = with_gateway_search_slot( + &RequestId("cancelled-search-recovered".to_owned()), + &slots, + async { "recovered-result" }, + ) + .await; + let Ok(recovered) = recovered else { + panic!("search admission should recover after underlying completion"); + }; + assert_eq!(recovered, "recovered-result"); + } +} diff --git a/src/api/request_logs.rs b/src/api/request_logs.rs new file mode 100644 index 000000000..51ce67240 --- /dev/null +++ b/src/api/request_logs.rs @@ -0,0 +1,284 @@ +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use tokio::sync::mpsc::{self, error::TrySendError}; + +use crate::{ + catalog::{CatalogError, CatalogStore, NewRequestLog}, + database::Database, +}; + +const DEFAULT_CAPACITY: usize = 1_024; +const MAX_BATCH_SIZE: usize = 64; + +#[derive(Clone)] +pub(super) struct GatewayRequestLogSink { + sender: mpsc::Sender, + database: Database, + telemetry: Arc, +} + +struct QueuedRequestLog { + log: NewRequestLog, + _database_guard: Database, +} + +#[derive(Default)] +struct RequestLogTelemetry { + dropped: AtomicU64, + dropped_full: AtomicU64, + dropped_closed: AtomicU64, + write_failures: AtomicU64, + writes: AtomicU64, +} + +#[derive(Clone, Copy)] +enum DropReason { + Full, + Closed, +} + +impl GatewayRequestLogSink { + pub(super) fn new(database: Database, catalog: CatalogStore) -> Self { + Self::with_capacity(database, catalog, DEFAULT_CAPACITY) + } + + pub(super) fn with_capacity( + database: Database, + catalog: CatalogStore, + capacity: usize, + ) -> Self { + assert!(capacity > 0, "request-log sink capacity must be positive"); + let (sender, receiver) = mpsc::channel(capacity); + let telemetry = Arc::new(RequestLogTelemetry::default()); + tokio::spawn(consume(receiver, catalog, telemetry.clone())); + Self { + sender, + database, + telemetry, + } + } + + pub(super) fn try_record(&self, log: NewRequestLog) -> bool { + let queued = QueuedRequestLog { + log, + _database_guard: self.database.clone(), + }; + match self.sender.try_send(queued) { + Ok(()) => true, + Err(TrySendError::Full(queued)) => { + self.telemetry + .record_drop(DropReason::Full, &queued.log.request_id); + false + } + Err(TrySendError::Closed(queued)) => { + self.telemetry + .record_drop(DropReason::Closed, &queued.log.request_id); + false + } + } + } + + #[cfg(test)] + fn counters(&self) -> RequestLogSinkCounters { + self.telemetry.counters() + } +} + +impl RequestLogTelemetry { + fn record_drop(&self, reason: DropReason, request_id: &str) { + let reason_total = match reason { + DropReason::Full => increment(&self.dropped_full), + DropReason::Closed => increment(&self.dropped_closed), + }; + let dropped_total = increment(&self.dropped); + if should_emit(dropped_total) { + tracing::warn!( + event = "gateway_request_log_dropped", + reason = reason.as_str(), + request_id, + dropped_total, + reason_total, + "gateway request log was not queued" + ); + } + } + + fn record_write_failure(&self, request_id: &str, error: &CatalogError) { + let write_failures = increment(&self.write_failures); + if should_emit(write_failures) { + tracing::error!( + event = "gateway_request_log_write_failed", + request_id, + error = %error, + write_failures, + "gateway request log write failed" + ); + } + } + + fn record_write(&self) { + increment(&self.writes); + } + + #[cfg(test)] + fn counters(&self) -> RequestLogSinkCounters { + RequestLogSinkCounters { + dropped: self.dropped.load(Ordering::Relaxed), + dropped_full: self.dropped_full.load(Ordering::Relaxed), + dropped_closed: self.dropped_closed.load(Ordering::Relaxed), + write_failures: self.write_failures.load(Ordering::Relaxed), + writes: self.writes.load(Ordering::Relaxed), + } + } +} + +impl DropReason { + fn as_str(self) -> &'static str { + match self { + Self::Full => "full", + Self::Closed => "closed", + } + } +} + +async fn consume( + mut receiver: mpsc::Receiver, + catalog: CatalogStore, + telemetry: Arc, +) { + let mut batch = Vec::with_capacity(MAX_BATCH_SIZE); + while let Some(queued) = receiver.recv().await { + batch.push(queued); + while batch.len() < MAX_BATCH_SIZE { + match receiver.try_recv() { + Ok(queued) => batch.push(queued), + Err(_) => break, + } + } + + for queued in batch.drain(..) { + let QueuedRequestLog { + log, + _database_guard, + } = queued; + let request_id = log.request_id.clone(); + match catalog.record_request(log).await { + Ok(()) => telemetry.record_write(), + Err(error) => telemetry.record_write_failure(&request_id, &error), + } + } + } +} + +fn increment(counter: &AtomicU64) -> u64 { + counter.fetch_add(1, Ordering::Relaxed).saturating_add(1) +} + +fn should_emit(total: u64) -> bool { + total.is_power_of_two() +} + +#[cfg(test)] +#[derive(Debug, Eq, PartialEq)] +struct RequestLogSinkCounters { + dropped: u64, + dropped_full: u64, + dropped_closed: u64, + write_failures: u64, + writes: u64, +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use tempfile::TempDir; + use tokio::{sync::mpsc, time::timeout}; + + use super::{GatewayRequestLogSink, RequestLogTelemetry}; + use crate::{ + AppConfig, + catalog::{CatalogStore, NewRequestLog, RequestOutcome, RequestSurface}, + database::{Database, OpenedDatabase}, + }; + + fn request_log(request_id: &str) -> NewRequestLog { + NewRequestLog { + request_id: request_id.to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Mcp, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.example.run".to_owned()), + outcome: RequestOutcome::PendingApproval, + error_code: Some("approval_required".to_owned()), + duration_ms: 37, + approval_id: Some("approval-1".to_owned()), + created_at: 1_750_000_000, + } + } + + async fn open_database() -> (TempDir, Database, CatalogStore) { + let directory = tempfile::tempdir().expect("temporary data directory"); + let OpenedDatabase { database, .. } = + Database::open(&AppConfig::new(directory.path().into())) + .await + .expect("test database opens"); + let catalog = CatalogStore::new(database.pool.clone(), database.keyring.clone()); + (directory, database, catalog) + } + + #[tokio::test] + async fn bounded_sender_drops_without_waiting_when_full() { + let (_directory, database, _catalog) = open_database().await; + let (sender, _receiver) = mpsc::channel(1); + let telemetry = Arc::new(RequestLogTelemetry::default()); + let sink = GatewayRequestLogSink { + sender, + database, + telemetry, + }; + + assert!(sink.try_record(request_log("queued"))); + assert!(!sink.try_record(request_log("dropped"))); + assert_eq!(sink.counters().dropped, 1); + assert_eq!(sink.counters().dropped_full, 1); + assert_eq!(sink.counters().dropped_closed, 0); + } + + #[tokio::test] + async fn consumer_recovers_after_a_failed_record_and_preserves_metadata() { + let (_directory, database, catalog) = open_database().await; + let sink = GatewayRequestLogSink::with_capacity(database, catalog.clone(), 2); + + assert!(sink.try_record(request_log(""))); + assert!(sink.try_record(request_log("recorded"))); + + let stored = timeout(Duration::from_secs(2), async { + loop { + let counters = sink.counters(); + if counters.write_failures == 1 + && counters.writes == 1 + && let Ok(stored) = catalog.request_log("recorded").await + { + break stored; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the consumer continues after a failed record"); + + assert_eq!(stored.request_id, "recorded"); + assert_eq!(stored.surface, RequestSurface::Mcp); + assert_eq!(stored.path_snapshot.as_deref(), Some("tools.example.run")); + assert_eq!(stored.outcome, RequestOutcome::PendingApproval); + assert_eq!(stored.error_code.as_deref(), Some("approval_required")); + assert_eq!(stored.duration_ms, 37); + assert_eq!(stored.approval_id.as_deref(), Some("approval-1")); + assert_eq!(stored.created_at, 1_750_000_000); + } +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs new file mode 100644 index 000000000..34e8c5519 --- /dev/null +++ b/src/catalog/mod.rs @@ -0,0 +1,564 @@ +mod search; +mod store; + +use std::{collections::BTreeMap, fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::crypto::CryptoError; + +pub use store::CatalogStore; + +pub const DEFAULT_PAGE_LIMIT: u32 = 50; +pub const MAX_PAGE_LIMIT: u32 = 100; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuditActor { + System, + Admin { id: i64 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AuditContext<'a> { + request_id: Option<&'a str>, + actor: AuditActor, +} + +impl<'a> AuditContext<'a> { + pub const fn system(request_id: Option<&'a str>) -> Self { + Self { + request_id, + actor: AuditActor::System, + } + } + + pub const fn admin(request_id: &'a str, id: i64) -> Self { + Self { + request_id: Some(request_id), + actor: AuditActor::Admin { id }, + } + } + + pub(crate) const fn request_id(self) -> Option<&'a str> { + self.request_id + } + + pub(crate) const fn actor_admin_id(self) -> Option { + match self.actor { + AuditActor::System => None, + AuditActor::Admin { id } => Some(id), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + Openapi, + Graphql, + McpHttp, + McpStdio, +} + +impl SourceKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Openapi => "openapi", + Self::Graphql => "graphql", + Self::McpHttp => "mcp_http", + Self::McpStdio => "mcp_stdio", + } + } +} + +impl fmt::Display for SourceKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for SourceKind { + type Err = CatalogError; + + fn from_str(value: &str) -> Result { + match value { + "openapi" => Ok(Self::Openapi), + "graphql" => Ok(Self::Graphql), + "mcp_http" => Ok(Self::McpHttp), + "mcp_stdio" => Ok(Self::McpStdio), + _ => Err(CatalogError::CorruptData("unknown source kind")), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceHealth { + Unknown, + Healthy, + Error, +} + +impl FromStr for SourceHealth { + type Err = CatalogError; + + fn from_str(value: &str) -> Result { + match value { + "unknown" => Ok(Self::Unknown), + "healthy" => Ok(Self::Healthy), + "error" => Ok(Self::Error), + _ => Err(CatalogError::CorruptData("unknown source health")), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + OpenapiDocument, + GraphqlSchema, + McpCapabilities, + Metadata, +} + +impl ArtifactKind { + pub fn as_str(self) -> &'static str { + match self { + Self::OpenapiDocument => "openapi_document", + Self::GraphqlSchema => "graphql_schema", + Self::McpCapabilities => "mcp_capabilities", + Self::Metadata => "metadata", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolMode { + Enabled, + Ask, + Disabled, +} + +impl ToolMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Ask => "ask", + Self::Disabled => "disabled", + } + } +} + +impl fmt::Display for ToolMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for ToolMode { + type Err = CatalogError; + + fn from_str(value: &str) -> Result { + match value { + "enabled" => Ok(Self::Enabled), + "ask" => Ok(Self::Ask), + "disabled" => Ok(Self::Disabled), + _ => Err(CatalogError::CorruptData("unknown tool mode")), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ModeProvenance { + ToolOverride, + SourceOverride, + Intrinsic, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveMode { + pub mode: ToolMode, + pub provenance: ModeProvenance, +} + +#[derive(Clone, Debug)] +pub struct CreateSource { + pub kind: SourceKind, + pub preferred_slug: String, + pub display_name: String, + pub description: Option, + pub configuration: serde_json::Map, +} + +#[derive(Clone, Debug)] +pub struct UpdateSource { + pub display_name: String, + pub description: Option, + pub configuration: serde_json::Map, + pub expected_revision: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceRecord { + pub id: String, + pub kind: SourceKind, + pub slug: String, + pub display_name: String, + pub description: Option, + pub configuration: serde_json::Map, + pub mode_override: Option, + pub health_status: SourceHealth, + pub health_error_code: Option, + pub revision: i64, + pub catalog_revision: i64, + pub created_at: i64, + pub updated_at: i64, + pub last_refreshed_at: Option, + pub tool_count: i64, + pub tombstoned_tool_count: i64, +} + +#[derive(Clone, Debug)] +pub struct StagedTool { + pub stable_key: String, + pub preferred_name: String, + pub display_name: String, + pub description: Option, + pub input_schema: Value, + pub output_schema: Option, + pub input_typescript: Option, + pub output_typescript: Option, + pub typescript_definitions: BTreeMap, + pub intrinsic_mode: ToolMode, +} + +#[derive(Clone, Debug)] +pub struct CatalogSnapshot { + pub expected_source_revision: i64, + pub expected_credential_revision: Option, + pub artifacts: Vec, + pub tools: Vec, +} + +#[derive(Clone, Debug)] +pub struct StagedArtifact { + pub kind: ArtifactKind, + pub stable_key: String, + pub content: Value, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogSyncResult { + pub source_id: String, + pub source_revision: i64, + pub catalog_revision: i64, + pub global_revision: i64, + pub active_tool_count: usize, + pub tombstoned_tool_count: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolSummary { + pub id: String, + pub source_id: String, + pub source_slug: String, + pub stable_key: String, + pub local_name: String, + pub callable_path: String, + pub sandbox_path: String, + pub display_name: String, + pub description: Option, + pub intrinsic_mode: ToolMode, + pub mode_override: Option, + pub effective_mode: EffectiveMode, + pub present: bool, + pub revision: i64, + pub created_at: i64, + pub updated_at: i64, + pub last_seen_at: i64, + pub tombstoned_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolRecord { + pub id: String, + pub source_id: String, + pub source_slug: String, + pub stable_key: String, + pub local_name: String, + pub callable_path: String, + pub sandbox_path: String, + pub display_name: String, + pub description: Option, + pub input_schema: Value, + pub output_schema: Option, + pub input_typescript: Option, + pub output_typescript: Option, + pub typescript_definitions: BTreeMap, + pub intrinsic_mode: ToolMode, + pub mode_override: Option, + pub effective_mode: EffectiveMode, + pub present: bool, + pub revision: i64, + pub created_at: i64, + pub updated_at: i64, + pub last_seen_at: i64, + pub tombstoned_at: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ListToolsFilter { + pub query: Option, + pub source_id: Option, + pub effective_mode: Option, + pub include_tombstoned: bool, + pub limit: u32, + pub offset: u32, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolPage { + pub items: Vec, + pub total: usize, + pub has_more: bool, + pub next_offset: Option, + pub catalog_revision: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkToolModeResult { + pub updated_count: usize, + pub catalog_revision: i64, + pub source_revisions: std::collections::BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolDiscoveryResult { + pub path: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub integration: String, + pub score: i64, + pub effective_mode: ToolMode, + pub requires_approval: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveryPage { + pub items: Vec, + pub total: usize, + pub has_more: bool, + pub next_offset: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribedTool { + pub path: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_typescript: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_typescript: Option, + pub type_script_definitions: BTreeMap, + pub input_schema: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + pub effective_mode: EffectiveMode, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvocationLookup { + pub tool_id: String, + pub source_id: String, + pub callable_path: String, + pub sandbox_path: String, + pub effective_mode: ToolMode, + pub requires_approval: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialPayload { + pub schema_version: u32, + pub payload: Value, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredCredential { + pub revision: i64, + pub credential: CredentialPayload, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestSurface { + Admin, + Gateway, + Cli, + Mcp, +} + +impl RequestSurface { + pub fn as_str(self) -> &'static str { + match self { + Self::Admin => "admin", + Self::Gateway => "gateway", + Self::Cli => "cli", + Self::Mcp => "mcp", + } + } +} + +impl FromStr for RequestSurface { + type Err = CatalogError; + + fn from_str(value: &str) -> Result { + match value { + "admin" => Ok(Self::Admin), + "gateway" => Ok(Self::Gateway), + "cli" => Ok(Self::Cli), + "mcp" => Ok(Self::Mcp), + _ => Err(CatalogError::CorruptData("unknown request surface")), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestOutcome { + Succeeded, + Failed, + PendingApproval, + Denied, +} + +impl RequestOutcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::PendingApproval => "pending_approval", + Self::Denied => "denied", + } + } +} + +impl FromStr for RequestOutcome { + type Err = CatalogError; + + fn from_str(value: &str) -> Result { + match value { + "succeeded" => Ok(Self::Succeeded), + "failed" => Ok(Self::Failed), + "pending_approval" => Ok(Self::PendingApproval), + "denied" => Ok(Self::Denied), + _ => Err(CatalogError::CorruptData("unknown request outcome")), + } + } +} + +#[derive(Clone, Debug)] +pub struct NewRequestLog { + pub request_id: String, + pub actor_api_token_id: Option, + pub surface: RequestSurface, + pub source_id: Option, + pub tool_id: Option, + pub path_snapshot: Option, + pub outcome: RequestOutcome, + pub error_code: Option, + pub duration_ms: u64, + pub approval_id: Option, + pub created_at: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestLogRecord { + pub request_id: String, + pub actor_api_token_id: Option, + pub surface: RequestSurface, + pub source_id: Option, + pub tool_id: Option, + pub path_snapshot: Option, + pub outcome: RequestOutcome, + pub error_code: Option, + pub duration_ms: i64, + pub approval_id: Option, + pub created_at: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestLogPage { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Error)] +pub enum CatalogError { + #[error("catalog storage failed: {0}")] + Database(#[from] sqlx::Error), + #[error("credential protection failed: {0}")] + Crypto(#[from] CryptoError), + #[error("catalog payload encoding failed: {0}")] + Json(#[from] serde_json::Error), + #[error("{message}")] + Validation { code: &'static str, message: String }, + #[error("{entity} was not found")] + NotFound { entity: &'static str }, + #[error("{scope} revision conflict: expected {expected}, found {actual}")] + RevisionConflict { + scope: &'static str, + expected: i64, + actual: i64, + }, + #[error("tool not found: {path}")] + ToolNotFound { path: String }, + #[error("tool disabled: {path}")] + ToolDisabled { path: String }, + #[error("stored catalog data is invalid: {0}")] + CorruptData(&'static str), +} + +pub(crate) fn effective_mode( + intrinsic: ToolMode, + source_override: Option, + tool_override: Option, +) -> EffectiveMode { + if let Some(mode) = tool_override { + EffectiveMode { + mode, + provenance: ModeProvenance::ToolOverride, + } + } else if let Some(mode) = source_override { + EffectiveMode { + mode, + provenance: ModeProvenance::SourceOverride, + } + } else { + EffectiveMode { + mode: intrinsic, + provenance: ModeProvenance::Intrinsic, + } + } +} diff --git a/src/catalog/search.rs b/src/catalog/search.rs new file mode 100644 index 000000000..aa393e937 --- /dev/null +++ b/src/catalog/search.rs @@ -0,0 +1,383 @@ +use std::collections::{BTreeSet, HashSet}; + +use super::{DiscoveryPage, ToolDiscoveryResult, ToolSummary}; + +const PATH_WEIGHT: i64 = 12; +const INTEGRATION_WEIGHT: i64 = 8; +const NAME_WEIGHT: i64 = 10; +const DESCRIPTION_WEIGHT: i64 = 5; +const MAX_GENERATED_TRIGRAM_TERMS: usize = 1_024; + +pub(super) struct CandidateExpressions { + pub(super) word: String, + pub(super) trigram: Option, + pub(super) short_bigram: Option, + pub(super) short_unigram: String, +} + +pub(super) fn candidate_expressions(query: &str) -> Option { + let mut tokens = tokenize(query); + tokens.sort_unstable(); + tokens.dedup(); + if tokens.is_empty() { + return None; + } + + let word = tokens + .iter() + .map(|token| format!(r#""{token}"*"#)) + .collect::>() + .join(" OR "); + let mut trigram_terms = BTreeSet::new(); + let mut bigram_terms = BTreeSet::new(); + let mut unigram_terms = BTreeSet::new(); + for token in &tokens { + let bytes = token.as_bytes(); + if let Some(first) = bytes.first() { + unigram_terms.insert(encode_short_gram(std::slice::from_ref(first))); + } + if bytes.len() >= 2 { + bigram_terms.insert(encode_short_gram(&bytes[..2])); + } + for end in 3..=bytes.len() { + if trigram_terms.len() == MAX_GENERATED_TRIGRAM_TERMS { + break; + } + trigram_terms.insert(token[..end].to_owned()); + } + } + + Some(CandidateExpressions { + word, + trigram: quoted_terms(trigram_terms), + short_bigram: quoted_terms(bigram_terms), + short_unigram: quoted_terms(unigram_terms).expect("a nonempty search token has a unigram"), + }) +} + +pub(super) fn namespace_prefix(namespace: Option<&str>) -> Option { + let tokens = tokenize(namespace?); + (!tokens.is_empty()).then(|| tokens.join(" ")) +} + +pub(super) fn normalize_for_index(value: &str) -> String { + normalize(value) +} + +pub(super) fn query_tokens(value: &str) -> Vec { + tokenize(value) +} + +pub(super) fn short_gram_document(values: &[&str]) -> String { + let mut grams = BTreeSet::new(); + for value in values { + for token in tokenize(value) { + let bytes = token.as_bytes(); + for byte in bytes { + grams.insert(encode_short_gram(std::slice::from_ref(byte))); + } + for gram in bytes.windows(2) { + grams.insert(encode_short_gram(gram)); + } + } + } + grams.into_iter().collect::>().join(" ") +} + +fn quoted_terms(terms: BTreeSet) -> Option { + (!terms.is_empty()).then(|| { + terms + .into_iter() + .map(|term| format!(r#""{term}""#)) + .collect::>() + .join(" OR ") + }) +} + +fn encode_short_gram(gram: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(2 + gram.len() * 2); + encoded.push('g'); + encoded.push(char::from(b'0' + gram.len() as u8)); + for byte in gram { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +struct PreparedField { + raw: String, + tokens: Vec, +} + +struct FieldScore { + score: i64, + matched_tokens: HashSet, + exact_phrase_match: bool, +} + +pub(super) fn search( + tools: &[ToolSummary], + query: &str, + namespace: Option<&str>, + limit: usize, + offset: usize, +) -> DiscoveryPage { + let normalized_query = normalize(query); + let query_tokens = tokenize(query); + if normalized_query.is_empty() || query_tokens.is_empty() { + return DiscoveryPage { + items: Vec::new(), + total: 0, + has_more: false, + next_offset: None, + }; + } + + let mut ranked = tools + .iter() + .filter(|tool| namespace_matches(tool, namespace)) + .filter_map(|tool| score_tool(tool, &normalized_query, &query_tokens)) + .collect::>(); + ranked.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.path.cmp(&right.path)) + }); + + let total = ranked.len(); + let start = offset.min(total); + let items = ranked + .into_iter() + .skip(start) + .take(limit) + .collect::>(); + let consumed = start + items.len(); + let has_more = consumed < total; + DiscoveryPage { + items, + total, + has_more, + next_offset: has_more.then_some(consumed), + } +} + +fn score_tool( + tool: &ToolSummary, + normalized_query: &str, + query_tokens: &[String], +) -> Option { + let path = prepare(&tool.sandbox_path); + let integration = prepare(&tool.source_slug); + let name = prepare(&tool.local_name); + let description = prepare(tool.description.as_deref().unwrap_or_default()); + let fields = [ + score_field(normalized_query, query_tokens, &path, PATH_WEIGHT), + score_field( + normalized_query, + query_tokens, + &integration, + INTEGRATION_WEIGHT, + ), + score_field(normalized_query, query_tokens, &name, NAME_WEIGHT), + score_field( + normalized_query, + query_tokens, + &description, + DESCRIPTION_WEIGHT, + ), + ]; + + let mut score = 0_i64; + let mut matched_tokens = HashSet::new(); + let mut exact_phrase_match = false; + for field in fields { + score += field.score; + exact_phrase_match |= field.exact_phrase_match; + matched_tokens.extend(field.matched_tokens); + } + if matched_tokens.is_empty() { + return None; + } + + let coverage = matched_tokens.len() as f64 / query_tokens.len() as f64; + let minimum_coverage = if query_tokens.len() <= 2 { 1.0 } else { 0.6 }; + if coverage < minimum_coverage && !exact_phrase_match { + return None; + } + if coverage == 1.0 { + score += 25; + } else { + score += (coverage * 10.0).round() as i64; + } + if path.tokens.first() == query_tokens.first() || name.tokens.first() == query_tokens.first() { + score += 8; + } + if path.raw == normalized_query || name.raw == normalized_query { + score += 20; + } + + Some(ToolDiscoveryResult { + path: tool.sandbox_path.clone(), + name: tool.local_name.clone(), + description: tool.description.clone(), + integration: tool.source_slug.clone(), + score, + effective_mode: tool.effective_mode.mode, + requires_approval: tool.effective_mode.mode == super::ToolMode::Ask, + }) +} + +fn score_field( + query: &str, + query_tokens: &[String], + field: &PreparedField, + weight: i64, +) -> FieldScore { + if field.raw.is_empty() { + return FieldScore { + score: 0, + matched_tokens: HashSet::new(), + exact_phrase_match: false, + }; + } + + let mut score = 0_i64; + let mut matched_tokens = HashSet::new(); + let exact_phrase_match = !query.is_empty() && field.raw.contains(query); + if !query.is_empty() { + if field.raw == query { + score += weight * 14; + } else if field.raw.starts_with(query) { + score += weight * 9; + } else if exact_phrase_match { + score += weight * 6; + } + } + + for token in query_tokens { + if field.tokens.contains(token) { + score += weight * 4; + matched_tokens.insert(token.clone()); + } else if field + .tokens + .iter() + .any(|candidate| candidate.starts_with(token) || token.starts_with(candidate)) + { + score += weight * 2; + matched_tokens.insert(token.clone()); + } else if field.raw.contains(token) { + score += weight; + matched_tokens.insert(token.clone()); + } + } + + FieldScore { + score, + matched_tokens, + exact_phrase_match, + } +} + +fn namespace_matches(tool: &ToolSummary, namespace: Option<&str>) -> bool { + let Some(namespace) = namespace else { + return true; + }; + if normalize(namespace).is_empty() { + return true; + } + let namespace_tokens = tokenize(namespace); + if namespace_tokens.is_empty() { + return true; + } + + prefix_matches(&tokenize(&tool.source_slug), &namespace_tokens) + || prefix_matches(&tokenize(&tool.sandbox_path), &namespace_tokens) +} + +fn prefix_matches(candidate: &[String], prefix: &[String]) -> bool { + prefix + .iter() + .enumerate() + .all(|(index, token)| candidate.get(index) == Some(token)) +} + +fn prepare(value: &str) -> PreparedField { + PreparedField { + raw: normalize(value), + tokens: tokenize(value), + } +} + +fn tokenize(value: &str) -> Vec { + normalize(value) + .split(|character: char| !character.is_ascii_lowercase() && !character.is_ascii_digit()) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .collect() +} + +fn normalize(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut previous: Option = None; + let mut separator_run = false; + for character in value.chars() { + if character.is_ascii_uppercase() + && previous + .is_some_and(|previous| previous.is_ascii_lowercase() || previous.is_ascii_digit()) + { + normalized.push(' '); + } + if matches!(character, '_' | '.' | '/' | ':' | '-') { + if !separator_run { + normalized.push(' '); + } + separator_run = true; + } else { + normalized.extend(character.to_lowercase()); + separator_run = false; + } + previous = Some(character); + } + normalized.trim().to_owned() +} + +#[cfg(test)] +mod tests { + use super::{ + candidate_expressions, namespace_prefix, normalize, short_gram_document, tokenize, + }; + + #[test] + fn normalization_matches_the_typescript_catalog_rules() { + assert_eq!(normalize("getHTTP/User-ID"), "get http user id"); + assert_eq!( + tokenize("GitHub.getRepo:v2"), + ["git", "hub", "get", "repo", "v2"] + ); + } + + #[test] + fn sql_candidate_terms_are_normalized_and_escaped_as_tokens() { + let expressions = candidate_expressions("GetRepo repo").expect("tokens should compile"); + assert_eq!(expressions.word, r#""get"* OR "repo"*"#); + assert_eq!( + expressions.trigram.as_deref(), + Some(r#""get" OR "rep" OR "repo""#) + ); + assert_eq!( + expressions.short_bigram.as_deref(), + Some(r#""g26765" OR "g27265""#) + ); + assert_eq!(expressions.short_unigram, r#""g167" OR "g172""#); + assert_eq!( + namespace_prefix(Some("GitHub/GetRepo")), + Some("git hub get repo".to_owned()) + ); + assert!(candidate_expressions("---").is_none()); + assert_eq!(namespace_prefix(Some("---")), None); + assert!(short_gram_document(&["github"]).contains("g26974")); + } +} diff --git a/src/catalog/store.rs b/src/catalog/store.rs new file mode 100644 index 000000000..fb866a211 --- /dev/null +++ b/src/catalog/store.rs @@ -0,0 +1,2853 @@ +use std::{ + collections::{HashMap, HashSet}, + str::FromStr, + sync::Arc, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde_json::{Map, Value, json}; +use sqlx::{FromRow, SqlitePool, Transaction}; +use tokio::sync::{Mutex, Semaphore}; +use uuid::Uuid; + +use super::{ + ArtifactKind, AuditContext, BulkToolModeResult, CatalogError, CatalogSnapshot, + CatalogSyncResult, CreateSource, CredentialPayload, DEFAULT_PAGE_LIMIT, DescribedTool, + DiscoveryPage, InvocationLookup, ListToolsFilter, MAX_PAGE_LIMIT, NewRequestLog, + RequestLogPage, RequestLogRecord, RequestOutcome, RequestSurface, SourceHealth, SourceKind, + SourceRecord, StoredCredential, ToolMode, ToolPage, ToolRecord, ToolSummary, UpdateSource, + effective_mode, search, +}; +use crate::{crypto::Keyring, unix_timestamp}; + +const CREDENTIAL_PURPOSE: &str = "source-credential-v1"; +const RESERVED_SOURCE_SLUGS: [&str; 4] = ["tools", "search", "describe", "executor"]; +const TOOL_ERROR_TYPESCRIPT: &str = + "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; +const TOOL_HTTP_META_TYPESCRIPT: &str = "{ status: number; headers: { [k: string]: string; } }"; +const TOOL_FILE_TYPESCRIPT: &str = r#"{ _tag: "ToolFile"; name?: string; mimeType: string; encoding: "base64"; data: string; byteLength: number; }"#; +const MAX_SEARCH_CANDIDATES: i64 = 4_096; +const MAX_SEARCH_QUERY_CHARACTERS: usize = 256; +const MAX_SEARCH_QUERY_BYTES: usize = 1_024; +const MAX_SEARCH_QUERY_TOKENS: usize = 64; +const MAX_SEARCH_TOKEN_BYTES: usize = 256; +const MAX_ACTIVE_TOOLS_PER_SOURCE: usize = 100_000; +const MAX_TOMBSTONED_TOOLS_PER_SOURCE: usize = 25_000; +const MAX_TOOL_HISTORY_PER_SOURCE: usize = + MAX_ACTIVE_TOOLS_PER_SOURCE + MAX_TOMBSTONED_TOOLS_PER_SOURCE; +const MAX_REQUEST_LOG_ROWS: i64 = 10_000; +const MAX_AUDIT_EVENT_ROWS: i64 = 10_000; +const MAX_AUDIT_METADATA_BYTES: usize = 64 * 1024; +const MAX_ARTIFACT_COUNT: usize = 4_096; +const MAX_ARTIFACT_BYTES: usize = 8 * 1024 * 1024; +const MAX_ARTIFACT_KEY_BYTES: usize = 1024; +const MAX_SCHEMA_BYTES: usize = 2 * 1024 * 1024; +const MAX_TYPESCRIPT_DEFINITIONS_BYTES: usize = 2 * 1024 * 1024; +const MAX_TYPESCRIPT_PREVIEW_BYTES: usize = 256 * 1024; +const MAX_TOOL_STABLE_KEY_BYTES: usize = 2 * 1024; +const MAX_TOOL_NAME_BYTES: usize = 1024; +const MAX_TOOL_DESCRIPTION_BYTES: usize = 16 * 1024; +const MAX_SEARCH_DESCRIPTION_BYTES: usize = 32 * 1024; +const MAX_SHORT_GRAM_DOCUMENT_BYTES: usize = 16 * 1024; +const MAX_LOCAL_NAME_BYTES: usize = 128; +const MAX_CATALOG_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Clone)] +pub struct CatalogStore { + pool: SqlitePool, + keyring: Keyring, + write_lock: Arc>, + request_log_writes: Arc, +} + +#[derive(FromRow)] +struct SourceRow { + id: String, + kind: String, + slug: String, + display_name: String, + description: Option, + configuration_json: String, + mode_override: Option, + health_status: String, + health_error_code: Option, + revision: i64, + catalog_revision: i64, + created_at: i64, + updated_at: i64, + last_refreshed_at: Option, + tool_count: i64, + tombstoned_tool_count: i64, +} + +#[derive(FromRow)] +struct ToolRow { + id: String, + source_id: String, + source_slug: String, + source_mode_override: Option, + stable_key: String, + local_name: String, + display_name: String, + description: Option, + input_schema_json: String, + output_schema_json: Option, + input_typescript: Option, + output_typescript: Option, + typescript_definitions_json: String, + intrinsic_mode: String, + mode_override: Option, + present: i64, + revision: i64, + created_at: i64, + updated_at: i64, + last_seen_at: i64, + tombstoned_at: Option, +} + +#[derive(FromRow)] +struct ToolSummaryRow { + id: String, + source_id: String, + source_slug: String, + source_mode_override: Option, + stable_key: String, + local_name: String, + display_name: String, + description: Option, + intrinsic_mode: String, + mode_override: Option, + present: i64, + revision: i64, + created_at: i64, + updated_at: i64, + last_seen_at: i64, + tombstoned_at: Option, +} + +#[derive(FromRow)] +struct RequestLogRow { + request_id: String, + actor_api_token_id: Option, + surface: String, + source_id: Option, + tool_id: Option, + path_snapshot: Option, + outcome: String, + error_code: Option, + duration_ms: i64, + approval_id: Option, + created_at: i64, +} + +struct PreparedTool { + stable_key: String, + preferred_name: String, + display_name: String, + description: Option, + search_description: String, + search_short_grams: String, + input_schema_json: String, + output_schema_json: Option, + input_typescript: Option, + output_typescript: Option, + typescript_definitions_json: String, + intrinsic_mode: ToolMode, +} + +struct PreparedArtifact { + kind: ArtifactKind, + stable_key: String, + content_json: String, +} + +struct PreparedSnapshot { + tools: Vec, + artifacts: Vec, +} + +#[derive(Clone, Copy)] +struct PayloadLimits { + artifact_count: usize, + artifact: usize, + artifact_key: usize, + schema: usize, + typescript_definitions: usize, + typescript_preview: usize, + tool_stable_key: usize, + tool_name: usize, + tool_description: usize, + search_description: usize, + short_gram_document: usize, + local_name: usize, + aggregate: usize, +} + +impl Default for PayloadLimits { + fn default() -> Self { + Self { + artifact_count: MAX_ARTIFACT_COUNT, + artifact: MAX_ARTIFACT_BYTES, + artifact_key: MAX_ARTIFACT_KEY_BYTES, + schema: MAX_SCHEMA_BYTES, + typescript_definitions: MAX_TYPESCRIPT_DEFINITIONS_BYTES, + typescript_preview: MAX_TYPESCRIPT_PREVIEW_BYTES, + tool_stable_key: MAX_TOOL_STABLE_KEY_BYTES, + tool_name: MAX_TOOL_NAME_BYTES, + tool_description: MAX_TOOL_DESCRIPTION_BYTES, + search_description: MAX_SEARCH_DESCRIPTION_BYTES, + short_gram_document: MAX_SHORT_GRAM_DOCUMENT_BYTES, + local_name: MAX_LOCAL_NAME_BYTES, + aggregate: MAX_CATALOG_PAYLOAD_BYTES, + } + } +} + +struct PayloadBudget { + limits: PayloadLimits, + consumed: usize, +} + +struct NameAllocator { + used: HashSet, + next_suffix: HashMap, + candidate_probes: usize, +} + +#[derive(Eq, Hash, PartialEq)] +struct CollisionNamespace { + prefix: String, + separator: char, + suffix_digits: u32, +} + +impl CatalogStore { + pub(crate) fn new(pool: SqlitePool, keyring: Keyring) -> Self { + Self { + pool, + keyring, + write_lock: Arc::new(Mutex::new(())), + request_log_writes: Arc::new(Semaphore::new(1)), + } + } + + pub fn pool(&self) -> &SqlitePool { + &self.pool + } + + pub async fn global_revision(&self) -> Result { + Ok( + sqlx::query_scalar("SELECT revision FROM catalog_state WHERE id = 1") + .fetch_one(&self.pool) + .await?, + ) + } + + pub async fn create_source( + &self, + input: CreateSource, + audit: AuditContext<'_>, + ) -> Result { + let display_name = validate_text( + "invalid_source_name", + "Source names must contain between 1 and 200 characters.", + &input.display_name, + 200, + )?; + let description = validate_optional_text( + "invalid_source_description", + "Source descriptions may contain at most 2000 characters.", + input.description.as_deref(), + 2000, + )?; + let base_slug = normalize_source_slug(&input.preferred_slug); + let source_id = Uuid::new_v4().to_string(); + let configuration_json = serde_json::to_string(&input.configuration)?; + let now = unix_timestamp(); + let _write = self.write_lock.lock().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let mut used_slugs = sqlx::query_scalar::<_, String>("SELECT slug FROM sources") + .fetch_all(&mut *transaction) + .await? + .into_iter() + .collect::>(); + used_slugs.extend(RESERVED_SOURCE_SLUGS.into_iter().map(str::to_owned)); + let slug = NameAllocator::new(used_slugs).allocate(&base_slug, 63, '_'); + let search_short_grams = search::short_gram_document(&[&slug]); + sqlx::query( + "INSERT INTO sources \ + (id, kind, slug, search_short_grams, display_name, description, configuration_json, \ + created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&source_id) + .bind(input.kind.as_str()) + .bind(&slug) + .bind(search_short_grams) + .bind(display_name) + .bind(description) + .bind(configuration_json) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + bump_global_revision(&mut transaction, now).await?; + insert_audit( + &mut transaction, + audit, + "source.created", + Some(&source_id), + None, + Some(&format!("tools.{slug}")), + json!({ "kind": input.kind, "slug": slug }), + now, + ) + .await?; + transaction.commit().await?; + self.source(&source_id).await + } + + pub async fn list_sources(&self) -> Result, CatalogError> { + Ok(self.list_sources_snapshot().await?.0) + } + + pub async fn list_sources_snapshot(&self) -> Result<(Vec, i64), CatalogError> { + let mut transaction = self.pool.begin().await?; + let sources = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_ALL) + .fetch_all(&mut *transaction) + .await? + .into_iter() + .map(SourceRecord::try_from) + .collect::, _>>()?; + let revision = sqlx::query_scalar("SELECT revision FROM catalog_state WHERE id = 1") + .fetch_one(&mut *transaction) + .await?; + transaction.commit().await?; + Ok((sources, revision)) + } + + pub async fn source(&self, source_id: &str) -> Result { + sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(source_id) + .fetch_optional(&self.pool) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })? + .try_into() + } + + pub async fn update_source( + &self, + source_id: &str, + input: UpdateSource, + audit: AuditContext<'_>, + ) -> Result { + let display_name = validate_text( + "invalid_source_name", + "Source names must contain between 1 and 200 characters.", + &input.display_name, + 200, + )?; + let description = validate_optional_text( + "invalid_source_description", + "Source descriptions may contain at most 2000 characters.", + input.description.as_deref(), + 2000, + )?; + let configuration_json = serde_json::to_string(&input.configuration)?; + let _write = self.write_lock.lock().await; + let now = unix_timestamp(); + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let changed = sqlx::query( + "UPDATE sources SET display_name = ?, description = ?, configuration_json = ?, \ + revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?", + ) + .bind(display_name) + .bind(description) + .bind(configuration_json) + .bind(now) + .bind(source_id) + .bind(input.expected_revision) + .execute(&mut *transaction) + .await? + .rows_affected(); + if changed == 0 { + return Err(revision_or_not_found( + &mut transaction, + "sources", + source_id, + "source", + input.expected_revision, + ) + .await?); + } + let global_revision = bump_global_revision(&mut transaction, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.updated", + Some(source_id), + None, + Some(&source_path), + json!({ "globalRevision": global_revision }), + now, + ) + .await?; + transaction.commit().await?; + self.source(source_id).await + } + + pub async fn delete_source( + &self, + source_id: &str, + audit: AuditContext<'_>, + ) -> Result<(), CatalogError> { + let _write = self.write_lock.lock().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let source = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(source_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })?; + let path = format!("tools.{}", source.slug); + insert_audit( + &mut transaction, + audit, + "source.deleted", + Some(source_id), + None, + Some(&path), + json!({ "slug": source.slug, "kind": source.kind }), + unix_timestamp(), + ) + .await?; + sqlx::query("DELETE FROM tool_search WHERE source_id = ?") + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM tool_search_trigram WHERE source_id = ?") + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM tool_search_short WHERE source_id = ?") + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM sources WHERE id = ?") + .bind(source_id) + .execute(&mut *transaction) + .await?; + bump_global_revision(&mut transaction, unix_timestamp()).await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn set_source_mode( + &self, + source_id: &str, + mode: Option, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + let _write = self.write_lock.lock().await; + let now = unix_timestamp(); + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let changed = sqlx::query( + "UPDATE sources SET mode_override = ?, revision = revision + 1, updated_at = ? \ + WHERE id = ? AND revision = ?", + ) + .bind(mode.map(ToolMode::as_str)) + .bind(now) + .bind(source_id) + .bind(expected_revision) + .execute(&mut *transaction) + .await? + .rows_affected(); + if changed == 0 { + return Err(revision_or_not_found( + &mut transaction, + "sources", + source_id, + "source", + expected_revision, + ) + .await?); + } + let global_revision = bump_global_revision(&mut transaction, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.mode_changed", + Some(source_id), + None, + Some(&source_path), + json!({ "modeOverride": mode, "globalRevision": global_revision }), + now, + ) + .await?; + transaction.commit().await?; + self.source(source_id).await + } + + pub async fn put_credential( + &self, + source_id: &str, + credential: &CredentialPayload, + expected_revision: Option, + audit: AuditContext<'_>, + ) -> Result<(), CatalogError> { + if credential.schema_version == 0 { + return Err(validation( + "invalid_credential_schema", + "Credential schema versions must be positive.", + )); + } + let plaintext = serde_json::to_vec(&credential.payload)?; + let ciphertext = self + .keyring + .encrypt(CREDENTIAL_PURPOSE, source_id, &plaintext)?; + let now = unix_timestamp(); + let _write = self.write_lock.lock().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + ensure_source_exists(&mut transaction, source_id).await?; + let actual_revision = sqlx::query_scalar::<_, i64>( + "SELECT revision FROM source_credentials WHERE source_id = ?", + ) + .bind(source_id) + .fetch_optional(&mut *transaction) + .await?; + if actual_revision != expected_revision { + return Err(CatalogError::RevisionConflict { + scope: "credential", + expected: expected_revision.unwrap_or(-1), + actual: actual_revision.unwrap_or(-1), + }); + } + if let Some(actual_revision) = actual_revision { + let changed = sqlx::query( + "UPDATE source_credentials SET schema_version = ?, payload_ciphertext = ?, \ + revision = revision + 1, updated_at = ? WHERE source_id = ? AND revision = ?", + ) + .bind(i64::from(credential.schema_version)) + .bind(ciphertext) + .bind(now) + .bind(source_id) + .bind(actual_revision) + .execute(&mut *transaction) + .await? + .rows_affected(); + if changed == 0 { + return Err(CatalogError::RevisionConflict { + scope: "credential", + expected: actual_revision, + actual: sqlx::query_scalar( + "SELECT revision FROM source_credentials WHERE source_id = ?", + ) + .bind(source_id) + .fetch_optional(&mut *transaction) + .await? + .unwrap_or(-1), + }); + } + } else { + sqlx::query( + "INSERT INTO source_credentials \ + (source_id, schema_version, payload_ciphertext, revision, created_at, updated_at) \ + VALUES (?, ?, ?, 0, ?, ?)", + ) + .bind(source_id) + .bind(i64::from(credential.schema_version)) + .bind(ciphertext) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + } + increment_source_and_global(&mut transaction, source_id, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.credential_changed", + Some(source_id), + None, + Some(&source_path), + json!({ "schemaVersion": credential.schema_version }), + now, + ) + .await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn credential( + &self, + source_id: &str, + ) -> Result, CatalogError> { + let credential = sqlx::query_as::<_, (i64, Vec, i64)>( + "SELECT schema_version, payload_ciphertext, revision FROM source_credentials WHERE source_id = ?", + ) + .bind(source_id) + .fetch_optional(&self.pool) + .await?; + credential + .map(|(schema_version, ciphertext, revision)| { + let plaintext = self + .keyring + .decrypt(CREDENTIAL_PURPOSE, source_id, &ciphertext)?; + Ok(StoredCredential { + revision, + credential: CredentialPayload { + schema_version: u32::try_from(schema_version) + .map_err(|_| CatalogError::CorruptData("credential schema version"))?, + payload: serde_json::from_slice(&plaintext)?, + }, + }) + }) + .transpose() + } + + pub async fn sync_catalog( + &self, + source_id: &str, + snapshot: CatalogSnapshot, + audit: AuditContext<'_>, + ) -> Result { + let expected_source_revision = snapshot.expected_source_revision; + let expected_credential_revision = snapshot.expected_credential_revision; + let prepared = prepare_snapshot(snapshot)?; + let now = unix_timestamp(); + let _write = self.write_lock.lock().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let actual_source_revision = + sqlx::query_scalar::<_, i64>("SELECT revision FROM sources WHERE id = ?") + .bind(source_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })?; + if actual_source_revision != expected_source_revision { + return Err(CatalogError::RevisionConflict { + scope: "source", + expected: expected_source_revision, + actual: actual_source_revision, + }); + } + let actual_credential_revision = sqlx::query_scalar::<_, i64>( + "SELECT revision FROM source_credentials WHERE source_id = ?", + ) + .bind(source_id) + .fetch_optional(&mut *transaction) + .await?; + if actual_credential_revision != expected_credential_revision { + return Err(CatalogError::RevisionConflict { + scope: "credential", + expected: expected_credential_revision.unwrap_or(-1), + actual: actual_credential_revision.unwrap_or(-1), + }); + } + let existing = sqlx::query_as::<_, (String, String, i64)>( + "SELECT stable_key, local_name, present FROM tools WHERE source_id = ?", + ) + .bind(source_id) + .fetch_all(&mut *transaction) + .await?; + let existing_by_key = existing + .into_iter() + .map(|(stable_key, local_name, present)| (stable_key, (local_name, present != 0))) + .collect::>(); + let staged_keys = prepared + .tools + .iter() + .map(|tool| tool.stable_key.clone()) + .collect::>(); + validate_tool_history(&existing_by_key, &staged_keys)?; + + let artifact_keys = prepared + .artifacts + .iter() + .map(|artifact| { + ( + artifact.kind.as_str().to_owned(), + artifact.stable_key.clone(), + ) + }) + .collect::>(); + let existing_artifact_keys = sqlx::query_as::<_, (String, String)>( + "SELECT artifact_kind, stable_key FROM source_artifacts WHERE source_id = ?", + ) + .bind(source_id) + .fetch_all(&mut *transaction) + .await?; + for artifact in &prepared.artifacts { + sqlx::query( + "INSERT INTO source_artifacts \ + (id, source_id, artifact_kind, stable_key, content_json, revision, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, 0, ?, ?) \ + ON CONFLICT(source_id, artifact_kind, stable_key) DO UPDATE SET \ + content_json = excluded.content_json, revision = source_artifacts.revision + 1, \ + updated_at = excluded.updated_at", + ) + .bind(Uuid::new_v4().to_string()) + .bind(source_id) + .bind(artifact.kind.as_str()) + .bind(&artifact.stable_key) + .bind(&artifact.content_json) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + } + for (kind, stable_key) in existing_artifact_keys { + if !artifact_keys.contains(&(kind.clone(), stable_key.clone())) { + sqlx::query( + "DELETE FROM source_artifacts \ + WHERE source_id = ? AND artifact_kind = ? AND stable_key = ?", + ) + .bind(source_id) + .bind(kind) + .bind(stable_key) + .execute(&mut *transaction) + .await?; + } + } + let used_names = existing_by_key + .values() + .map(|(local_name, _)| local_name.clone()) + .collect::>(); + let mut name_allocator = NameAllocator::new(used_names); + + for tool in &prepared.tools { + let local_name = existing_by_key + .get(&tool.stable_key) + .map(|(local_name, _)| local_name.clone()) + .unwrap_or_else(|| { + let base = normalize_tool_name(&tool.preferred_name); + name_allocator.allocate(&base, 128, '_') + }); + sqlx::query( + "INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, search_description, \ + search_short_grams, \ + input_schema_json, output_schema_json, input_typescript, output_typescript, \ + typescript_definitions_json, intrinsic_mode, present, revision, \ + created_at, updated_at, last_seen_at, tombstoned_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, NULL) \ + ON CONFLICT(source_id, stable_key) DO UPDATE SET \ + display_name = excluded.display_name, description = excluded.description, \ + search_description = excluded.search_description, \ + search_short_grams = excluded.search_short_grams, \ + input_schema_json = excluded.input_schema_json, \ + output_schema_json = excluded.output_schema_json, \ + input_typescript = excluded.input_typescript, \ + output_typescript = excluded.output_typescript, \ + typescript_definitions_json = excluded.typescript_definitions_json, \ + intrinsic_mode = excluded.intrinsic_mode, present = 1, \ + revision = tools.revision + 1, updated_at = excluded.updated_at, \ + last_seen_at = excluded.last_seen_at, tombstoned_at = NULL", + ) + .bind(Uuid::new_v4().to_string()) + .bind(source_id) + .bind(&tool.stable_key) + .bind(local_name) + .bind(&tool.display_name) + .bind(&tool.description) + .bind(&tool.search_description) + .bind(&tool.search_short_grams) + .bind(&tool.input_schema_json) + .bind(&tool.output_schema_json) + .bind(&tool.input_typescript) + .bind(&tool.output_typescript) + .bind(&tool.typescript_definitions_json) + .bind(tool.intrinsic_mode.as_str()) + .bind(now) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + } + + let missing = existing_by_key + .iter() + .filter(|(stable_key, (_, present))| *present && !staged_keys.contains(*stable_key)) + .map(|(stable_key, _)| stable_key) + .collect::>(); + for stable_key in &missing { + sqlx::query( + "UPDATE tools SET present = 0, revision = revision + 1, updated_at = ?, \ + tombstoned_at = ? WHERE source_id = ? AND stable_key = ? AND present = 1", + ) + .bind(now) + .bind(now) + .bind(source_id) + .bind(stable_key) + .execute(&mut *transaction) + .await?; + } + + sqlx::query("DELETE FROM tool_search WHERE source_id = ?") + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO tool_search \ + (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ + SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ + replace(tools.local_name, '_', ' '), tools.search_description, \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.present = 1", + ) + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM tool_search_trigram WHERE source_id = ?") + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO tool_search_trigram \ + (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ + SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ + replace(tools.local_name, '_', ' '), tools.search_description, \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.present = 1", + ) + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM tool_search_short WHERE source_id = ?") + .bind(source_id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO tool_search_short (source_id, tool_id, grams) \ + SELECT tools.source_id, tools.id, \ + sources.search_short_grams || ' ' || tools.search_short_grams \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.present = 1", + ) + .bind(source_id) + .execute(&mut *transaction) + .await?; + + let revisions = sqlx::query_as::<_, (i64, i64)>( + "UPDATE sources SET revision = revision + 1, \ + catalog_revision = catalog_revision + 1, health_status = 'healthy', \ + health_error_code = NULL, updated_at = ?, last_refreshed_at = ? \ + WHERE id = ? RETURNING revision, catalog_revision", + ) + .bind(now) + .bind(now) + .bind(source_id) + .fetch_one(&mut *transaction) + .await?; + let global_revision = bump_global_revision(&mut transaction, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.catalog_refreshed", + Some(source_id), + None, + Some(&source_path), + json!({ + "catalogRevision": revisions.1, + "activeToolCount": prepared.tools.len(), + "artifactCount": prepared.artifacts.len(), + "tombstonedToolCount": missing.len(), + "globalRevision": global_revision + }), + now, + ) + .await?; + transaction.commit().await?; + Ok(CatalogSyncResult { + source_id: source_id.to_owned(), + source_revision: revisions.0, + catalog_revision: revisions.1, + global_revision, + active_tool_count: prepared.tools.len(), + tombstoned_tool_count: missing.len(), + }) + } + + pub async fn list_tools(&self, filter: ListToolsFilter) -> Result { + let tokens_json = serde_json::to_string( + &filter + .query + .as_deref() + .map(normalize_filter_query) + .unwrap_or_default(), + )?; + let source_id = filter.source_id.as_deref(); + let effective_mode = filter.effective_mode.map(ToolMode::as_str); + let include_tombstoned = i64::from(filter.include_tombstoned); + let limit = page_limit(filter.limit); + let offset = i64::from(filter.offset); + let mut transaction = self.pool.begin().await?; + let total = sqlx::query_scalar::<_, i64>(TOOL_LIST_COUNT) + .bind(include_tombstoned) + .bind(source_id) + .bind(source_id) + .bind(effective_mode) + .bind(effective_mode) + .bind(&tokens_json) + .fetch_one(&mut *transaction) + .await?; + let items = sqlx::query_as::<_, ToolSummaryRow>(TOOL_LIST_PAGE) + .bind(include_tombstoned) + .bind(source_id) + .bind(source_id) + .bind(effective_mode) + .bind(effective_mode) + .bind(tokens_json) + .bind(i64::try_from(limit).unwrap_or(i64::MAX)) + .bind(offset) + .fetch_all(&mut *transaction) + .await? + .into_iter() + .map(ToolSummary::try_from) + .collect::, _>>()?; + let catalog_revision = + sqlx::query_scalar("SELECT revision FROM catalog_state WHERE id = 1") + .fetch_one(&mut *transaction) + .await?; + transaction.commit().await?; + let total = usize::try_from(total).map_err(|_| CatalogError::CorruptData("tool count"))?; + let start = usize::try_from(filter.offset) + .unwrap_or(usize::MAX) + .min(total); + let consumed = start + items.len(); + let has_more = consumed < total; + Ok(ToolPage { + items, + total, + has_more, + next_offset: has_more.then_some(consumed), + catalog_revision, + }) + } + + pub async fn tool(&self, tool_id: &str) -> Result { + sqlx::query_as::<_, ToolRow>(TOOL_SELECT_BY_ID) + .bind(tool_id) + .fetch_optional(&self.pool) + .await? + .ok_or(CatalogError::NotFound { entity: "tool" })? + .try_into() + } + + pub async fn set_tool_mode( + &self, + tool_id: &str, + mode: Option, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + let _write = self.write_lock.lock().await; + let now = unix_timestamp(); + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let row = sqlx::query_as::<_, (String, String, String)>( + "SELECT tools.source_id, sources.slug, tools.local_name \ + FROM tools JOIN sources ON sources.id = tools.source_id WHERE tools.id = ?", + ) + .bind(tool_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "tool" })?; + let changed = sqlx::query( + "UPDATE tools SET mode_override = ?, revision = revision + 1, updated_at = ? \ + WHERE id = ? AND revision = ?", + ) + .bind(mode.map(ToolMode::as_str)) + .bind(now) + .bind(tool_id) + .bind(expected_revision) + .execute(&mut *transaction) + .await? + .rows_affected(); + if changed == 0 { + let actual = sqlx::query_scalar::<_, i64>("SELECT revision FROM tools WHERE id = ?") + .bind(tool_id) + .fetch_one(&mut *transaction) + .await?; + return Err(CatalogError::RevisionConflict { + scope: "tool", + expected: expected_revision, + actual, + }); + } + let source_revision = increment_source_revision(&mut transaction, &row.0, now).await?; + let global_revision = bump_global_revision(&mut transaction, now).await?; + let path = format!("tools.{}.{}", row.1, row.2); + insert_audit( + &mut transaction, + audit, + "tool.mode_changed", + Some(&row.0), + Some(tool_id), + Some(&path), + json!({ + "modeOverride": mode, + "sourceRevision": source_revision, + "globalRevision": global_revision + }), + now, + ) + .await?; + transaction.commit().await?; + self.tool(tool_id).await + } + + pub async fn bulk_set_source_tool_modes( + &self, + source_id: &str, + mode: Option, + expected_source_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + let _write = self.write_lock.lock().await; + let now = unix_timestamp(); + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let actual_revision = + sqlx::query_scalar::<_, i64>("SELECT revision FROM sources WHERE id = ?") + .bind(source_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })?; + if actual_revision != expected_source_revision { + return Err(CatalogError::RevisionConflict { + scope: "source", + expected: expected_source_revision, + actual: actual_revision, + }); + } + let updated_count = sqlx::query( + "UPDATE tools SET mode_override = ?, revision = revision + 1, updated_at = ? \ + WHERE source_id = ? AND present = 1", + ) + .bind(mode.map(ToolMode::as_str)) + .bind(now) + .bind(source_id) + .execute(&mut *transaction) + .await? + .rows_affected() as usize; + let source_revision = increment_source_revision(&mut transaction, source_id, now).await?; + let catalog_revision = bump_global_revision(&mut transaction, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "tool.mode_bulk_changed", + Some(source_id), + None, + Some(&source_path), + json!({ + "selection": "source", + "modeOverride": mode, + "updatedCount": updated_count, + "sourceRevision": source_revision, + "globalRevision": catalog_revision + }), + now, + ) + .await?; + transaction.commit().await?; + Ok(BulkToolModeResult { + updated_count, + catalog_revision, + source_revisions: [(source_id.to_owned(), source_revision)] + .into_iter() + .collect(), + }) + } + + pub async fn bulk_set_tool_modes( + &self, + tool_ids: &[String], + mode: Option, + expected_catalog_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + let mut unique_ids = tool_ids.to_vec(); + unique_ids.sort_unstable(); + unique_ids.dedup(); + if unique_ids.is_empty() || unique_ids.len() > 200 { + return Err(validation( + "invalid_tool_selection", + "Select between 1 and 200 tools for a bulk mode change.", + )); + } + let _write = self.write_lock.lock().await; + let now = unix_timestamp(); + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let actual_catalog_revision = + sqlx::query_scalar::<_, i64>("SELECT revision FROM catalog_state WHERE id = 1") + .fetch_one(&mut *transaction) + .await?; + if actual_catalog_revision != expected_catalog_revision { + return Err(CatalogError::RevisionConflict { + scope: "catalog", + expected: expected_catalog_revision, + actual: actual_catalog_revision, + }); + } + let mut source_ids = HashSet::new(); + let mut selected_tools = Vec::with_capacity(unique_ids.len()); + for tool_id in &unique_ids { + let (source_id, source_slug, local_name) = + sqlx::query_as::<_, (String, String, String)>( + "SELECT tools.source_id, sources.slug, tools.local_name \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.id = ? AND tools.present = 1", + ) + .bind(tool_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "tool" })?; + source_ids.insert(source_id); + selected_tools.push(json!({ + "toolId": tool_id, + "path": format!("tools.{source_slug}.{local_name}") + })); + } + for tool_id in &unique_ids { + sqlx::query( + "UPDATE tools SET mode_override = ?, revision = revision + 1, updated_at = ? \ + WHERE id = ?", + ) + .bind(mode.map(ToolMode::as_str)) + .bind(now) + .bind(tool_id) + .execute(&mut *transaction) + .await?; + } + let mut source_revisions = std::collections::BTreeMap::new(); + for source_id in source_ids { + let revision = increment_source_revision(&mut transaction, &source_id, now).await?; + source_revisions.insert(source_id, revision); + } + let catalog_revision = bump_global_revision(&mut transaction, now).await?; + insert_audit( + &mut transaction, + audit, + "tool.mode_bulk_changed", + None, + None, + None, + json!({ + "selection": "explicit", + "selectedTools": selected_tools, + "modeOverride": mode, + "updatedCount": unique_ids.len(), + "globalRevision": catalog_revision + }), + now, + ) + .await?; + transaction.commit().await?; + Ok(BulkToolModeResult { + updated_count: unique_ids.len(), + catalog_revision, + source_revisions, + }) + } + + pub async fn search_tools( + &self, + query: &str, + namespace: Option<&str>, + limit: u32, + offset: u32, + ) -> Result { + validate_search_input("query", query)?; + if let Some(namespace) = namespace { + validate_search_input("namespace", namespace)?; + } + let Some(expressions) = search::candidate_expressions(query) else { + return Ok(search::search( + &[], + query, + namespace, + page_limit(limit), + usize::try_from(offset).unwrap_or(usize::MAX), + )); + }; + let namespace_prefix = search::namespace_prefix(namespace); + let namespace_like = namespace_prefix + .as_ref() + .map(|namespace| format!("{namespace} %")); + let trigram_enabled = i64::from(expressions.trigram.is_some()); + let short_bigram_enabled = i64::from(expressions.short_bigram.is_some()); + let trigram = expressions + .trigram + .as_deref() + .unwrap_or(r#""executorsearchnomatchsentinel""#); + let short_bigram = expressions.short_bigram.as_deref().unwrap_or(r#""g2ffff""#); + let tools = sqlx::query_as::<_, ToolSummaryRow>(TOOL_SEARCH_CANDIDATES) + .bind(expressions.word) + .bind(trigram) + .bind(short_bigram) + .bind(expressions.short_unigram) + .bind(namespace_prefix.as_deref()) + .bind(namespace_prefix.as_deref()) + .bind(namespace_like.as_deref()) + .bind(MAX_SEARCH_CANDIDATES) + .bind(trigram_enabled) + .bind(short_bigram_enabled) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(ToolSummary::try_from) + .collect::, _>>()?; + Ok(search::search( + &tools, + query, + namespace, + page_limit(limit), + usize::try_from(offset).unwrap_or(usize::MAX), + )) + } + + pub async fn describe_tool(&self, path: &str) -> Result { + let tool = self.gateway_tool_by_path(path).await?; + let mut definitions = tool.typescript_definitions.clone(); + definitions.insert("ToolError".to_owned(), TOOL_ERROR_TYPESCRIPT.to_owned()); + definitions.insert( + "ToolHttpMeta".to_owned(), + TOOL_HTTP_META_TYPESCRIPT.to_owned(), + ); + definitions.insert("ToolFile".to_owned(), TOOL_FILE_TYPESCRIPT.to_owned()); + let output = tool.output_typescript.as_deref().unwrap_or("unknown"); + Ok(DescribedTool { + path: path.to_owned(), + name: tool.local_name, + description: tool.description, + input_typescript: tool.input_typescript, + output_typescript: Some(format!( + "{{ ok: true; data: {output}; http?: ToolHttpMeta }} | {{ ok: false; error: ToolError }}" + )), + type_script_definitions: definitions, + input_schema: tool.input_schema, + output_schema: tool.output_schema, + effective_mode: tool.effective_mode, + }) + } + + pub async fn guard_invocation(&self, path: &str) -> Result { + let tool = + self.tool_summary_by_path(path) + .await? + .ok_or_else(|| CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + })?; + if !tool.present { + return Err(CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + }); + } + if tool.effective_mode.mode == ToolMode::Disabled { + return Err(CatalogError::ToolDisabled { + path: tool.sandbox_path, + }); + } + Ok(InvocationLookup { + tool_id: tool.id, + source_id: tool.source_id, + callable_path: tool.callable_path, + sandbox_path: tool.sandbox_path, + effective_mode: tool.effective_mode.mode, + requires_approval: tool.effective_mode.mode == ToolMode::Ask, + }) + } + + pub async fn record_request(&self, log: NewRequestLog) -> Result<(), CatalogError> { + let duration_ms = i64::try_from(log.duration_ms).map_err(|_| { + validation( + "invalid_duration", + "Request duration is too large to store.", + ) + })?; + validate_log_text("request ID", &log.request_id, 128)?; + validate_optional_log_text("path snapshot", log.path_snapshot.as_deref(), 512)?; + validate_optional_log_text("error code", log.error_code.as_deref(), 128)?; + validate_optional_log_text("approval ID", log.approval_id.as_deref(), 128)?; + if (log.source_id.is_some() || log.tool_id.is_some()) && log.path_snapshot.is_none() { + return Err(validation( + "invalid_log_path", + "Catalog request logs with source or tool IDs require a path snapshot.", + )); + } + let _permit = self + .request_log_writes + .clone() + .try_acquire_owned() + .map_err(|_| { + validation( + "request_log_backpressure", + "Request logging is at capacity. The request log was not stored.", + ) + })?; + self.persist_request(log, duration_ms).await + } + + async fn persist_request( + &self, + log: NewRequestLog, + duration_ms: i64, + ) -> Result<(), CatalogError> { + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + sqlx::query( + "INSERT INTO request_logs \ + (request_id, actor_api_token_id, surface, source_id, tool_id, path_snapshot, \ + outcome, error_code, duration_ms, approval_id, created_at) \ + VALUES (?, \ + (SELECT id FROM api_tokens WHERE id = ?), ?, \ + (SELECT id FROM sources WHERE id = ?), \ + (SELECT id FROM tools WHERE id = ?), ?, ?, ?, ?, ?, ?)", + ) + .bind(log.request_id) + .bind(log.actor_api_token_id) + .bind(log.surface.as_str()) + .bind(log.source_id) + .bind(log.tool_id) + .bind(log.path_snapshot) + .bind(log.outcome.as_str()) + .bind(log.error_code) + .bind(duration_ms) + .bind(log.approval_id) + .bind(log.created_at) + .execute(&mut *transaction) + .await?; + sqlx::query( + "DELETE FROM request_logs WHERE request_id IN ( \ + SELECT request_id FROM request_logs \ + ORDER BY created_at DESC, request_id DESC LIMIT -1 OFFSET ?)", + ) + .bind(MAX_REQUEST_LOG_ROWS) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn request_log(&self, request_id: &str) -> Result { + sqlx::query_as::<_, RequestLogRow>(REQUEST_LOG_SELECT_BY_ID) + .bind(request_id) + .fetch_optional(&self.pool) + .await? + .ok_or(CatalogError::NotFound { + entity: "request log", + })? + .try_into() + } + + pub async fn list_request_logs( + &self, + cursor: Option<&str>, + limit: u32, + ) -> Result { + let limit = page_limit(limit); + let cursor = cursor.map(decode_cursor).transpose()?; + let mut rows = if let Some((created_at, request_id)) = cursor { + sqlx::query_as::<_, RequestLogRow>( + "SELECT request_id, actor_api_token_id, surface, source_id, tool_id, \ + path_snapshot, outcome, error_code, duration_ms, approval_id, created_at \ + FROM request_logs WHERE created_at < ? OR (created_at = ? AND request_id < ?) \ + ORDER BY created_at DESC, request_id DESC LIMIT ?", + ) + .bind(created_at) + .bind(created_at) + .bind(request_id) + .bind(i64::try_from(limit + 1).unwrap_or(i64::MAX)) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query_as::<_, RequestLogRow>( + "SELECT request_id, actor_api_token_id, surface, source_id, tool_id, \ + path_snapshot, outcome, error_code, duration_ms, approval_id, created_at \ + FROM request_logs ORDER BY created_at DESC, request_id DESC LIMIT ?", + ) + .bind(i64::try_from(limit + 1).unwrap_or(i64::MAX)) + .fetch_all(&self.pool) + .await? + }; + let has_more = rows.len() > limit; + rows.truncate(limit); + let next_cursor = has_more.then(|| { + let last = rows.last().expect("a page with more rows is not empty"); + encode_cursor(last.created_at, &last.request_id) + }); + Ok(RequestLogPage { + items: rows + .into_iter() + .map(RequestLogRecord::try_from) + .collect::>()?, + next_cursor, + }) + } + + async fn tool_by_path(&self, path: &str) -> Result, CatalogError> { + let Some((source_slug, local_name)) = parse_tool_path(path) else { + return Ok(None); + }; + sqlx::query_as::<_, ToolRow>(TOOL_SELECT_BY_PATH) + .bind(source_slug) + .bind(local_name) + .fetch_optional(&self.pool) + .await? + .map(ToolRecord::try_from) + .transpose() + } + + async fn tool_summary_by_path(&self, path: &str) -> Result, CatalogError> { + let Some((source_slug, local_name)) = parse_tool_path(path) else { + return Ok(None); + }; + sqlx::query_as::<_, ToolSummaryRow>(TOOL_SUMMARY_SELECT_BY_PATH) + .bind(source_slug) + .bind(local_name) + .fetch_optional(&self.pool) + .await? + .map(ToolSummary::try_from) + .transpose() + } + + async fn gateway_tool_by_path(&self, path: &str) -> Result { + let tool = self + .tool_by_path(path) + .await? + .ok_or_else(|| CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + })?; + if !tool.present || tool.effective_mode.mode == ToolMode::Disabled { + return Err(CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + }); + } + Ok(tool) + } +} + +impl TryFrom for SourceRecord { + type Error = CatalogError; + + fn try_from(row: SourceRow) -> Result { + let configuration = serde_json::from_str::>(&row.configuration_json)?; + Ok(Self { + id: row.id, + kind: SourceKind::from_str(&row.kind)?, + slug: row.slug, + display_name: row.display_name, + description: row.description, + configuration, + mode_override: parse_optional_mode(row.mode_override)?, + health_status: SourceHealth::from_str(&row.health_status)?, + health_error_code: row.health_error_code, + revision: row.revision, + catalog_revision: row.catalog_revision, + created_at: row.created_at, + updated_at: row.updated_at, + last_refreshed_at: row.last_refreshed_at, + tool_count: row.tool_count, + tombstoned_tool_count: row.tombstoned_tool_count, + }) + } +} + +impl TryFrom for ToolRecord { + type Error = CatalogError; + + fn try_from(row: ToolRow) -> Result { + let intrinsic_mode = ToolMode::from_str(&row.intrinsic_mode)?; + let source_override = parse_optional_mode(row.source_mode_override)?; + let mode_override = parse_optional_mode(row.mode_override)?; + let effective_mode = effective_mode(intrinsic_mode, source_override, mode_override); + let sandbox_path = format!("{}.{}", row.source_slug, row.local_name); + let callable_path = format!("tools.{sandbox_path}"); + Ok(Self { + id: row.id, + source_id: row.source_id, + source_slug: row.source_slug, + stable_key: row.stable_key, + local_name: row.local_name, + callable_path, + sandbox_path, + display_name: row.display_name, + description: row.description, + input_schema: serde_json::from_str(&row.input_schema_json)?, + output_schema: row + .output_schema_json + .map(|json| serde_json::from_str(&json)) + .transpose()?, + input_typescript: row.input_typescript, + output_typescript: row.output_typescript, + typescript_definitions: serde_json::from_str(&row.typescript_definitions_json)?, + intrinsic_mode, + mode_override, + effective_mode, + present: row.present != 0, + revision: row.revision, + created_at: row.created_at, + updated_at: row.updated_at, + last_seen_at: row.last_seen_at, + tombstoned_at: row.tombstoned_at, + }) + } +} + +impl TryFrom for ToolSummary { + type Error = CatalogError; + + fn try_from(row: ToolSummaryRow) -> Result { + let intrinsic_mode = ToolMode::from_str(&row.intrinsic_mode)?; + let source_override = parse_optional_mode(row.source_mode_override)?; + let mode_override = parse_optional_mode(row.mode_override)?; + let effective_mode = effective_mode(intrinsic_mode, source_override, mode_override); + let sandbox_path = format!("{}.{}", row.source_slug, row.local_name); + let callable_path = format!("tools.{sandbox_path}"); + Ok(Self { + id: row.id, + source_id: row.source_id, + source_slug: row.source_slug, + stable_key: row.stable_key, + local_name: row.local_name, + callable_path, + sandbox_path, + display_name: row.display_name, + description: row.description, + intrinsic_mode, + mode_override, + effective_mode, + present: row.present != 0, + revision: row.revision, + created_at: row.created_at, + updated_at: row.updated_at, + last_seen_at: row.last_seen_at, + tombstoned_at: row.tombstoned_at, + }) + } +} + +impl TryFrom for RequestLogRecord { + type Error = CatalogError; + + fn try_from(row: RequestLogRow) -> Result { + Ok(Self { + request_id: row.request_id, + actor_api_token_id: row.actor_api_token_id, + surface: RequestSurface::from_str(&row.surface)?, + source_id: row.source_id, + tool_id: row.tool_id, + path_snapshot: row.path_snapshot, + outcome: RequestOutcome::from_str(&row.outcome)?, + error_code: row.error_code, + duration_ms: row.duration_ms, + approval_id: row.approval_id, + created_at: row.created_at, + }) + } +} + +const SOURCE_SELECT_ALL: &str = "SELECT sources.id, sources.kind, sources.slug, sources.display_name, sources.description, \ + sources.configuration_json, sources.mode_override, sources.health_status, \ + sources.health_error_code, sources.revision, sources.catalog_revision, \ + sources.created_at, sources.updated_at, sources.last_refreshed_at, \ + (SELECT COUNT(*) FROM tools WHERE tools.source_id = sources.id AND tools.present = 1) AS tool_count, \ + (SELECT COUNT(*) FROM tools WHERE tools.source_id = sources.id AND tools.present = 0) AS tombstoned_tool_count \ + FROM sources ORDER BY sources.slug, sources.id"; + +const SOURCE_SELECT_BY_ID: &str = "SELECT sources.id, sources.kind, sources.slug, sources.display_name, sources.description, \ + sources.configuration_json, sources.mode_override, sources.health_status, \ + sources.health_error_code, sources.revision, sources.catalog_revision, \ + sources.created_at, sources.updated_at, sources.last_refreshed_at, \ + (SELECT COUNT(*) FROM tools WHERE tools.source_id = sources.id AND tools.present = 1) AS tool_count, \ + (SELECT COUNT(*) FROM tools WHERE tools.source_id = sources.id AND tools.present = 0) AS tombstoned_tool_count \ + FROM sources WHERE sources.id = ?"; + +const TOOL_SELECT_BY_ID: &str = "SELECT tools.id, tools.source_id, sources.slug AS source_slug, \ + sources.mode_override AS source_mode_override, tools.stable_key, tools.local_name, \ + tools.display_name, tools.description, tools.input_schema_json, tools.output_schema_json, \ + tools.input_typescript, tools.output_typescript, tools.typescript_definitions_json, \ + tools.intrinsic_mode, tools.mode_override, tools.present, tools.revision, \ + tools.created_at, tools.updated_at, tools.last_seen_at, tools.tombstoned_at \ + FROM tools JOIN sources ON sources.id = tools.source_id WHERE tools.id = ?"; + +const TOOL_SELECT_BY_PATH: &str = "SELECT tools.id, tools.source_id, sources.slug AS source_slug, \ + sources.mode_override AS source_mode_override, tools.stable_key, tools.local_name, \ + tools.display_name, tools.description, tools.input_schema_json, tools.output_schema_json, \ + tools.input_typescript, tools.output_typescript, tools.typescript_definitions_json, \ + tools.intrinsic_mode, tools.mode_override, tools.present, tools.revision, \ + tools.created_at, tools.updated_at, tools.last_seen_at, tools.tombstoned_at \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE sources.slug = ? AND tools.local_name = ?"; + +const TOOL_LIST_COUNT: &str = "SELECT COUNT(*) FROM tools \ + JOIN sources ON sources.id = tools.source_id \ + WHERE (? = 1 OR tools.present = 1) \ + AND (? IS NULL OR tools.source_id = ?) \ + AND (? IS NULL OR coalesce(tools.mode_override, sources.mode_override, tools.intrinsic_mode) = ?) \ + AND NOT EXISTS ( \ + SELECT 1 FROM json_each(?) AS query_tokens \ + WHERE instr(lower( \ + 'tools.' || sources.slug || '.' || tools.local_name || ' ' || tools.display_name || ' ' || \ + tools.stable_key || ' ' || coalesce(tools.description, '') || ' ' || sources.slug \ + ), CAST(query_tokens.value AS TEXT)) = 0)"; + +const TOOL_LIST_PAGE: &str = "SELECT tools.id, tools.source_id, sources.slug AS source_slug, \ + sources.mode_override AS source_mode_override, tools.stable_key, tools.local_name, \ + tools.display_name, tools.description, tools.intrinsic_mode, tools.mode_override, \ + tools.present, tools.revision, tools.created_at, tools.updated_at, \ + tools.last_seen_at, tools.tombstoned_at \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE (? = 1 OR tools.present = 1) \ + AND (? IS NULL OR tools.source_id = ?) \ + AND (? IS NULL OR coalesce(tools.mode_override, sources.mode_override, tools.intrinsic_mode) = ?) \ + AND NOT EXISTS ( \ + SELECT 1 FROM json_each(?) AS query_tokens \ + WHERE instr(lower( \ + 'tools.' || sources.slug || '.' || tools.local_name || ' ' || tools.display_name || ' ' || \ + tools.stable_key || ' ' || coalesce(tools.description, '') || ' ' || sources.slug \ + ), CAST(query_tokens.value AS TEXT)) = 0) \ + ORDER BY sources.slug, tools.local_name, tools.id LIMIT ? OFFSET ?"; + +const TOOL_SEARCH_CANDIDATES: &str = "WITH ranked_candidates AS ( \ + SELECT * FROM ( \ + SELECT tools.id AS tool_id, 0 AS priority, \ + bm25(tool_search, 0.0, 0.0, 8.0, 10.0, 5.0, 12.0) AS relevance \ + FROM tool_search JOIN tools ON tools.id = tool_search.tool_id \ + JOIN sources ON sources.id = tools.source_id \ + WHERE tool_search MATCH ?1 AND tools.present = 1 \ + AND coalesce(tools.mode_override, sources.mode_override, tools.intrinsic_mode) <> 'disabled' \ + AND (?5 IS NULL OR ( \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') = ?6 \ + OR replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') LIKE ?7)) \ + ORDER BY relevance, tools.id LIMIT ?8) \ + UNION ALL \ + SELECT * FROM ( \ + SELECT tools.id AS tool_id, 1 AS priority, \ + bm25(tool_search_trigram, 0.0, 0.0, 8.0, 10.0, 5.0, 12.0) AS relevance \ + FROM tool_search_trigram JOIN tools ON tools.id = tool_search_trigram.tool_id \ + JOIN sources ON sources.id = tools.source_id \ + WHERE ?9 = 1 AND tool_search_trigram MATCH ?2 AND tools.present = 1 \ + AND coalesce(tools.mode_override, sources.mode_override, tools.intrinsic_mode) <> 'disabled' \ + AND (?5 IS NULL OR ( \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') = ?6 \ + OR replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') LIKE ?7)) \ + ORDER BY relevance, tools.id LIMIT ?8) \ + UNION ALL \ + SELECT * FROM ( \ + SELECT tools.id AS tool_id, 2 AS priority, \ + bm25(tool_search_short, 0.0, 0.0, 1.0) AS relevance \ + FROM tool_search_short JOIN tools ON tools.id = tool_search_short.tool_id \ + JOIN sources ON sources.id = tools.source_id \ + WHERE ?10 = 1 AND tool_search_short MATCH ?3 AND tools.present = 1 \ + AND coalesce(tools.mode_override, sources.mode_override, tools.intrinsic_mode) <> 'disabled' \ + AND (?5 IS NULL OR ( \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') = ?6 \ + OR replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') LIKE ?7)) \ + ORDER BY relevance, tools.id LIMIT ?8) \ + UNION ALL \ + SELECT * FROM ( \ + SELECT tools.id AS tool_id, 3 AS priority, \ + bm25(tool_search_short, 0.0, 0.0, 1.0) AS relevance \ + FROM tool_search_short JOIN tools ON tools.id = tool_search_short.tool_id \ + JOIN sources ON sources.id = tools.source_id \ + WHERE tool_search_short MATCH ?4 AND tools.present = 1 \ + AND coalesce(tools.mode_override, sources.mode_override, tools.intrinsic_mode) <> 'disabled' \ + AND (?5 IS NULL OR ( \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') = ?6 \ + OR replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') LIKE ?7)) \ + ORDER BY relevance, tools.id LIMIT ?8) \ + ), best_priority AS ( \ + SELECT tool_id, min(priority) AS priority FROM ranked_candidates GROUP BY tool_id \ + ), deduped AS ( \ + SELECT ranked.tool_id, ranked.priority, min(ranked.relevance) AS relevance \ + FROM ranked_candidates AS ranked \ + JOIN best_priority AS best \ + ON best.tool_id = ranked.tool_id AND best.priority = ranked.priority \ + GROUP BY ranked.tool_id, ranked.priority \ + ), candidates AS ( \ + SELECT tool_id, priority, relevance FROM deduped \ + ORDER BY priority, relevance, tool_id LIMIT ?8 \ + ) \ + SELECT tools.id, tools.source_id, sources.slug AS source_slug, \ + sources.mode_override AS source_mode_override, tools.stable_key, tools.local_name, \ + tools.display_name, tools.description, tools.intrinsic_mode, tools.mode_override, \ + tools.present, tools.revision, tools.created_at, tools.updated_at, \ + tools.last_seen_at, tools.tombstoned_at \ + FROM candidates JOIN tools ON tools.id = candidates.tool_id \ + JOIN sources ON sources.id = tools.source_id \ + ORDER BY candidates.priority, candidates.relevance, sources.slug, tools.local_name, tools.id"; + +const TOOL_SUMMARY_SELECT_BY_PATH: &str = "SELECT tools.id, tools.source_id, sources.slug AS source_slug, \ + sources.mode_override AS source_mode_override, tools.stable_key, tools.local_name, \ + tools.display_name, tools.description, tools.intrinsic_mode, tools.mode_override, \ + tools.present, tools.revision, tools.created_at, tools.updated_at, \ + tools.last_seen_at, tools.tombstoned_at \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE sources.slug = ? AND tools.local_name = ?"; + +const REQUEST_LOG_SELECT_BY_ID: &str = "SELECT request_id, actor_api_token_id, surface, source_id, tool_id, path_snapshot, \ + outcome, error_code, duration_ms, approval_id, created_at \ + FROM request_logs WHERE request_id = ?"; + +fn prepare_snapshot(snapshot: CatalogSnapshot) -> Result { + prepare_snapshot_with_limits(snapshot, PayloadLimits::default()) +} + +fn prepare_snapshot_with_limits( + snapshot: CatalogSnapshot, + limits: PayloadLimits, +) -> Result { + if snapshot.tools.len() > MAX_ACTIVE_TOOLS_PER_SOURCE { + return Err(validation( + "catalog_too_large", + format!( + "A catalog refresh may contain at most {MAX_ACTIVE_TOOLS_PER_SOURCE} active tools." + ), + )); + } + validate_artifact_count(snapshot.artifacts.len(), limits.artifact_count)?; + let mut budget = PayloadBudget::new(limits); + let mut artifact_keys = HashSet::with_capacity(snapshot.artifacts.len()); + let mut artifacts = Vec::with_capacity(snapshot.artifacts.len()); + for artifact in snapshot.artifacts { + let stable_key = validate_stable_text( + "invalid_artifact_key", + "Artifact stable keys must contain between 1 and 512 characters.", + &artifact.stable_key, + 512, + )?; + if !artifact_keys.insert((artifact.kind.as_str(), stable_key.clone())) { + return Err(validation( + "duplicate_artifact_key", + "A staged catalog contains the same source artifact more than once.", + )); + } + budget.charge( + stable_key.len(), + limits.artifact_key, + "artifact_key_too_large", + "A staged artifact key exceeds the byte-size limit.", + )?; + budget.charge_aggregate(artifact.kind.as_str().len())?; + let content_json = serde_json::to_string(&artifact.content)?; + budget.charge( + content_json.len(), + limits.artifact, + "artifact_too_large", + "A staged artifact exceeds the serialized-size limit.", + )?; + artifacts.push(PreparedArtifact { + kind: artifact.kind, + stable_key, + content_json, + }); + } + artifacts.sort_by(|left, right| { + left.kind + .as_str() + .cmp(right.kind.as_str()) + .then_with(|| left.stable_key.cmp(&right.stable_key)) + }); + + let mut stable_keys = HashSet::with_capacity(snapshot.tools.len()); + let mut tools = Vec::with_capacity(snapshot.tools.len()); + for tool in snapshot.tools { + let stable_key = validate_stable_text( + "invalid_stable_key", + "Tool stable keys must contain between 1 and 1024 characters.", + &tool.stable_key, + 1024, + )?; + if !stable_keys.insert(stable_key.clone()) { + return Err(validation( + "duplicate_stable_key", + "A staged catalog contains the same stable tool key more than once.", + )); + } + budget.charge( + stable_key.len(), + limits.tool_stable_key, + "tool_key_too_large", + "A staged tool stable key exceeds the byte-size limit.", + )?; + let preferred_name = validate_text( + "invalid_tool_name", + "Tool names must contain between 1 and 300 characters.", + &tool.preferred_name, + 300, + )?; + budget.charge( + preferred_name.len(), + limits.tool_name, + "tool_name_too_large", + "A staged tool name exceeds the byte-size limit.", + )?; + let display_name = validate_text( + "invalid_tool_name", + "Tool display names must contain between 1 and 300 characters.", + &tool.display_name, + 300, + )?; + budget.charge( + display_name.len(), + limits.tool_name, + "tool_name_too_large", + "A staged tool name exceeds the byte-size limit.", + )?; + let description = validate_optional_text( + "invalid_tool_description", + "Tool descriptions may contain at most 4000 characters.", + tool.description.as_deref(), + 4000, + )?; + if let Some(description) = &description { + budget.charge( + description.len(), + limits.tool_description, + "tool_description_too_large", + "A staged tool description exceeds the byte-size limit.", + )?; + } + validate_optional_text( + "invalid_typescript", + "TypeScript previews may contain at most 100000 characters.", + tool.input_typescript.as_deref(), + 100_000, + )?; + if let Some(input_typescript) = &tool.input_typescript { + budget.charge( + input_typescript.len(), + limits.typescript_preview, + "typescript_too_large", + "A staged TypeScript preview exceeds the byte-size limit.", + )?; + } + validate_optional_text( + "invalid_typescript", + "TypeScript previews may contain at most 100000 characters.", + tool.output_typescript.as_deref(), + 100_000, + )?; + if let Some(output_typescript) = &tool.output_typescript { + budget.charge( + output_typescript.len(), + limits.typescript_preview, + "typescript_too_large", + "A staged TypeScript preview exceeds the byte-size limit.", + )?; + } + let search_description = description + .as_deref() + .map(search::normalize_for_index) + .unwrap_or_default(); + budget.charge( + search_description.len(), + limits.search_description, + "search_description_too_large", + "A normalized tool description exceeds the byte-size limit.", + )?; + let normalized_preferred_name = normalize_tool_name(&preferred_name); + let search_short_grams = + search::short_gram_document(&[&normalized_preferred_name, &search_description]); + budget.charge( + search_short_grams.len(), + limits.short_gram_document, + "search_index_too_large", + "A staged tool search index exceeds the byte-size limit.", + )?; + budget.charge_aggregate(limits.local_name)?; + budget.charge_aggregate(tool.intrinsic_mode.as_str().len())?; + let input_schema_json = serde_json::to_string(&tool.input_schema)?; + budget.charge( + input_schema_json.len(), + limits.schema, + "schema_too_large", + "A staged tool schema exceeds the serialized-size limit.", + )?; + let output_schema_json = tool + .output_schema + .as_ref() + .map(serde_json::to_string) + .transpose()?; + if let Some(output_schema_json) = &output_schema_json { + budget.charge( + output_schema_json.len(), + limits.schema, + "schema_too_large", + "A staged tool schema exceeds the serialized-size limit.", + )?; + } + let typescript_definitions_json = serde_json::to_string(&tool.typescript_definitions)?; + budget.charge( + typescript_definitions_json.len(), + limits.typescript_definitions, + "typescript_definitions_too_large", + "Staged TypeScript definitions exceed the serialized-size limit.", + )?; + tools.push(PreparedTool { + stable_key, + preferred_name, + display_name, + search_description, + search_short_grams, + description, + input_schema_json, + output_schema_json, + input_typescript: tool.input_typescript, + output_typescript: tool.output_typescript, + typescript_definitions_json, + intrinsic_mode: tool.intrinsic_mode, + }); + } + tools.sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + Ok(PreparedSnapshot { tools, artifacts }) +} + +fn parse_optional_mode(value: Option) -> Result, CatalogError> { + value.map(|value| ToolMode::from_str(&value)).transpose() +} + +fn normalize_source_slug(value: &str) -> String { + normalize_identifier(value, "source") +} + +fn normalize_tool_name(value: &str) -> String { + normalize_identifier(value, "tool") +} + +fn normalize_identifier(value: &str, fallback: &str) -> String { + let mut normalized = String::new(); + let mut separator_pending = false; + let mut previous: Option = None; + for character in value.chars() { + if character.is_ascii_alphanumeric() { + let camel_boundary = character.is_ascii_uppercase() + && previous.is_some_and(|previous| { + previous.is_ascii_lowercase() || previous.is_ascii_digit() + }); + if (separator_pending || camel_boundary) && !normalized.is_empty() { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + separator_pending = false; + } else if !normalized.is_empty() { + separator_pending = true; + } + previous = Some(character); + } + if normalized.is_empty() { + normalized.push_str(fallback); + } + if !normalized.starts_with(|character: char| character.is_ascii_lowercase()) { + normalized.insert_str(0, &format!("{fallback}_")); + } + normalized +} + +impl PayloadBudget { + fn new(limits: PayloadLimits) -> Self { + Self { + limits, + consumed: 0, + } + } + + fn charge( + &mut self, + bytes: usize, + item_limit: usize, + item_code: &'static str, + item_message: &'static str, + ) -> Result<(), CatalogError> { + if bytes > item_limit { + return Err(validation(item_code, item_message)); + } + self.charge_aggregate(bytes) + } + + fn charge_aggregate(&mut self, bytes: usize) -> Result<(), CatalogError> { + self.consumed = self.consumed.checked_add(bytes).ok_or_else(|| { + validation( + "catalog_payload_too_large", + "The staged catalog payload exceeds the aggregate serialized-size limit.", + ) + })?; + if self.consumed > self.limits.aggregate { + return Err(validation( + "catalog_payload_too_large", + "The staged catalog payload exceeds the aggregate serialized-size limit.", + )); + } + Ok(()) + } +} + +impl NameAllocator { + fn new(used: HashSet) -> Self { + Self { + used, + next_suffix: HashMap::new(), + candidate_probes: 0, + } + } + + fn allocate(&mut self, base: &str, maximum_length: usize, separator: char) -> String { + let base = &base[..base.len().min(maximum_length)]; + if self.used.insert(base.to_owned()) { + return base.to_owned(); + } + + let mut range_start = 2_u64; + loop { + let suffix_digits = range_start.ilog10() + 1; + let range_end = 10_u64 + .checked_pow(suffix_digits) + .map_or(u64::MAX, |next_power| next_power - 1); + let prefix_length = maximum_length + .saturating_sub(1 + suffix_digits as usize) + .min(base.len()); + let namespace = CollisionNamespace { + prefix: base[..prefix_length].to_owned(), + separator, + suffix_digits, + }; + let next_suffix = self.next_suffix.entry(namespace).or_insert(range_start); + while *next_suffix <= range_end { + let index = *next_suffix; + *next_suffix = index.saturating_add(1); + let suffix = format!("{separator}{index}"); + let candidate = format!("{}{suffix}", &base[..prefix_length]); + self.candidate_probes = self.candidate_probes.saturating_add(1); + if self.used.insert(candidate.clone()) { + return candidate; + } + } + range_start = range_end + .checked_add(1) + .expect("the integer suffix space is unbounded for catalog-sized allocations"); + } + } + + #[cfg(test)] + fn candidate_probes(&self) -> usize { + self.candidate_probes + } +} + +fn validate_artifact_count(count: usize, maximum: usize) -> Result<(), CatalogError> { + if count > maximum { + Err(validation( + "catalog_too_many_artifacts", + format!("A catalog refresh may contain at most {maximum} artifacts."), + )) + } else { + Ok(()) + } +} + +fn validate_tool_history( + existing: &HashMap, + staged_keys: &HashSet, +) -> Result<(), CatalogError> { + let new_unique_count = staged_keys + .iter() + .filter(|stable_key| !existing.contains_key(*stable_key)) + .count(); + let projected_history = existing + .len() + .checked_add(new_unique_count) + .ok_or_else(|| { + validation( + "catalog_too_large", + "The source tool history exceeds the supported ceiling.", + ) + })?; + let projected_tombstones = existing + .keys() + .filter(|stable_key| !staged_keys.contains(*stable_key)) + .count(); + if projected_history > MAX_TOOL_HISTORY_PER_SOURCE + || projected_tombstones > MAX_TOMBSTONED_TOOLS_PER_SOURCE + { + return Err(validation( + "catalog_too_large", + format!( + "A source may retain at most {MAX_ACTIVE_TOOLS_PER_SOURCE} active tools and \ + {MAX_TOMBSTONED_TOOLS_PER_SOURCE} tombstoned tools. Delete and recreate the \ + source to intentionally discard retained tool history." + ), + )); + } + Ok(()) +} + +fn validate_text( + code: &'static str, + message: &'static str, + value: &str, + maximum_length: usize, +) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > maximum_length || value.contains('\0') { + return Err(validation(code, message)); + } + Ok(value.to_owned()) +} + +fn validate_stable_text( + code: &'static str, + message: &'static str, + value: &str, + maximum_length: usize, +) -> Result { + if value.trim().is_empty() || value.chars().count() > maximum_length || value.contains('\0') { + return Err(validation(code, message)); + } + Ok(value.to_owned()) +} + +fn validate_optional_text( + code: &'static str, + message: &'static str, + value: Option<&str>, + maximum_length: usize, +) -> Result, CatalogError> { + value + .map(|value| { + if value.chars().count() > maximum_length || value.contains('\0') { + Err(validation(code, message)) + } else { + Ok(value.to_owned()) + } + }) + .transpose() +} + +fn validation(code: &'static str, message: impl Into) -> CatalogError { + CatalogError::Validation { + code, + message: message.into(), + } +} + +fn normalize_filter_query(query: &str) -> Vec { + query + .to_lowercase() + .split_whitespace() + .map(str::to_owned) + .collect() +} + +fn validate_search_input(label: &str, value: &str) -> Result<(), CatalogError> { + if value.len() > MAX_SEARCH_QUERY_BYTES || value.chars().count() > MAX_SEARCH_QUERY_CHARACTERS { + return Err(validation( + "invalid_search_query", + format!( + "The search {label} may contain at most {MAX_SEARCH_QUERY_CHARACTERS} characters \ + and {MAX_SEARCH_QUERY_BYTES} bytes." + ), + )); + } + let tokens = search::query_tokens(value); + if tokens.len() > MAX_SEARCH_QUERY_TOKENS + || tokens + .iter() + .any(|token| token.len() > MAX_SEARCH_TOKEN_BYTES) + { + return Err(validation( + "invalid_search_query", + format!( + "The search {label} may contain at most {MAX_SEARCH_QUERY_TOKENS} tokens, with \ + at most {MAX_SEARCH_TOKEN_BYTES} bytes per token." + ), + )); + } + Ok(()) +} + +fn page_limit(limit: u32) -> usize { + let limit = if limit == 0 { + DEFAULT_PAGE_LIMIT + } else { + limit + }; + usize::try_from(limit.min(MAX_PAGE_LIMIT)).unwrap_or(MAX_PAGE_LIMIT as usize) +} + +fn parse_tool_path(path: &str) -> Option<(&str, &str)> { + let sandbox_path = path.strip_prefix("tools.").unwrap_or(path); + let mut segments = sandbox_path.split('.'); + let source = segments.next()?; + let tool = segments.next()?; + (!source.is_empty() && !tool.is_empty() && segments.next().is_none()).then_some((source, tool)) +} + +fn normalize_sandbox_path(path: &str) -> String { + path.strip_prefix("tools.").unwrap_or(path).to_owned() +} + +async fn ensure_source_exists( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, +) -> Result<(), CatalogError> { + let exists = sqlx::query_scalar::<_, i64>("SELECT EXISTS(SELECT 1 FROM sources WHERE id = ?)") + .bind(source_id) + .fetch_one(&mut **transaction) + .await? + != 0; + if exists { + Ok(()) + } else { + Err(CatalogError::NotFound { entity: "source" }) + } +} + +async fn source_path_snapshot( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, +) -> Result { + let slug = sqlx::query_scalar::<_, String>("SELECT slug FROM sources WHERE id = ?") + .bind(source_id) + .fetch_one(&mut **transaction) + .await?; + Ok(format!("tools.{slug}")) +} + +async fn revision_or_not_found( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + _table: &'static str, + id: &str, + scope: &'static str, + expected: i64, +) -> Result { + let actual = sqlx::query_scalar::<_, i64>("SELECT revision FROM sources WHERE id = ?") + .bind(id) + .fetch_optional(&mut **transaction) + .await?; + Ok(match actual { + Some(actual) => CatalogError::RevisionConflict { + scope, + expected, + actual, + }, + None => CatalogError::NotFound { entity: scope }, + }) +} + +async fn increment_source_revision( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + now: i64, +) -> Result { + Ok(sqlx::query_scalar( + "UPDATE sources SET revision = revision + 1, updated_at = ? \ + WHERE id = ? RETURNING revision", + ) + .bind(now) + .bind(source_id) + .fetch_one(&mut **transaction) + .await?) +} + +async fn increment_source_and_global( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + now: i64, +) -> Result<(i64, i64), CatalogError> { + let source_revision = increment_source_revision(transaction, source_id, now).await?; + let global_revision = bump_global_revision(transaction, now).await?; + Ok((source_revision, global_revision)) +} + +async fn bump_global_revision( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + now: i64, +) -> Result { + Ok(sqlx::query_scalar( + "UPDATE catalog_state SET revision = revision + 1, updated_at = ? \ + WHERE id = 1 RETURNING revision", + ) + .bind(now) + .fetch_one(&mut **transaction) + .await?) +} + +#[allow(clippy::too_many_arguments)] +async fn insert_audit( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + audit: AuditContext<'_>, + action: &str, + source_id: Option<&str>, + tool_id: Option<&str>, + target_path_snapshot: Option<&str>, + metadata: Value, + created_at: i64, +) -> Result<(), CatalogError> { + insert_audit_with_limit( + transaction, + audit, + action, + source_id, + tool_id, + target_path_snapshot, + metadata, + created_at, + MAX_AUDIT_EVENT_ROWS, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn insert_audit_with_limit( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + audit: AuditContext<'_>, + action: &str, + source_id: Option<&str>, + tool_id: Option<&str>, + target_path_snapshot: Option<&str>, + metadata: Value, + created_at: i64, + maximum_rows: i64, +) -> Result<(), CatalogError> { + let metadata_json = serde_json::to_string(&metadata)?; + if metadata_json.len() > MAX_AUDIT_METADATA_BYTES { + return Err(validation( + "audit_metadata_too_large", + format!( + "Audit metadata may contain at most {MAX_AUDIT_METADATA_BYTES} serialized bytes." + ), + )); + } + sqlx::query( + "INSERT INTO audit_events \ + (id, request_id, actor_admin_id, action, source_id, tool_id, \ + target_path_snapshot, metadata_json, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(audit.request_id()) + .bind(audit.actor_admin_id()) + .bind(action) + .bind(source_id) + .bind(tool_id) + .bind(target_path_snapshot) + .bind(metadata_json) + .bind(created_at) + .execute(&mut **transaction) + .await?; + sqlx::query( + "DELETE FROM audit_events WHERE rowid IN ( \ + SELECT rowid FROM audit_events \ + ORDER BY rowid DESC LIMIT -1 OFFSET ?)", + ) + .bind(maximum_rows) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +fn validate_log_text(label: &str, value: &str, maximum_length: usize) -> Result<(), CatalogError> { + if value.is_empty() || value.len() > maximum_length || value.contains('\0') { + Err(validation( + "invalid_request_log", + format!("The request log {label} is invalid."), + )) + } else { + Ok(()) + } +} + +fn validate_optional_log_text( + label: &str, + value: Option<&str>, + maximum_length: usize, +) -> Result<(), CatalogError> { + if let Some(value) = value { + validate_log_text(label, value, maximum_length)?; + } + Ok(()) +} + +fn encode_cursor(created_at: i64, request_id: &str) -> String { + URL_SAFE_NO_PAD.encode(format!("{created_at}\n{request_id}")) +} + +fn decode_cursor(cursor: &str) -> Result<(i64, String), CatalogError> { + let decoded = URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| validation("invalid_cursor", "The request-log cursor is malformed."))?; + let decoded = String::from_utf8(decoded) + .map_err(|_| validation("invalid_cursor", "The request-log cursor is malformed."))?; + let (created_at, request_id) = decoded + .split_once('\n') + .ok_or_else(|| validation("invalid_cursor", "The request-log cursor is malformed."))?; + let created_at = created_at + .parse::() + .map_err(|_| validation("invalid_cursor", "The request-log cursor is malformed."))?; + validate_log_text("cursor request ID", request_id, 128)?; + Ok((created_at, request_id.to_owned())) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashSet}; + + use serde_json::json; + + use sqlx::sqlite::SqlitePoolOptions; + + use super::{ + CatalogSnapshot, NameAllocator, PayloadLimits, insert_audit_with_limit, + prepare_snapshot_with_limits, + }; + use crate::catalog::{ + ArtifactKind, AuditContext, CatalogError, StagedArtifact, StagedTool, ToolMode, + }; + + fn empty_snapshot() -> CatalogSnapshot { + CatalogSnapshot { + expected_source_revision: 0, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: Vec::new(), + } + } + + fn small_limits() -> PayloadLimits { + PayloadLimits { + artifact_count: 2, + artifact: 128, + artifact_key: 128, + schema: 128, + typescript_definitions: 128, + typescript_preview: 128, + tool_stable_key: 128, + tool_name: 128, + tool_description: 128, + search_description: 128, + short_gram_document: 1024, + local_name: 16, + aggregate: 256, + } + } + + fn staged_tool() -> StagedTool { + StagedTool { + stable_key: "tool".to_owned(), + preferred_name: "Tool".to_owned(), + display_name: "Tool".to_owned(), + description: None, + input_schema: json!({ "type": "object" }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Enabled, + } + } + + #[test] + fn identical_tool_names_allocate_linearly_and_deterministically_at_the_catalog_limit() { + let mut allocator = NameAllocator::new(HashSet::new()); + let mut allocated = HashSet::with_capacity(100_000); + for index in 1..=100_000 { + let name = allocator.allocate("run", 128, '_'); + assert!(allocated.insert(name.clone())); + if index == 1 { + assert_eq!(name, "run"); + } else if index == 100_000 { + assert_eq!(name, "run_100000"); + } + } + assert_eq!(allocator.candidate_probes(), 99_999); + } + + #[test] + fn collapsing_max_length_bases_share_suffix_probe_progress() { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let common_prefix = "a".repeat(126); + let mut bases = Vec::with_capacity(ALPHABET.len() * ALPHABET.len()); + for first in ALPHABET { + for second in ALPHABET { + bases.push(format!( + "{common_prefix}{}{}", + char::from(*first), + char::from(*second) + )); + } + } + assert_eq!(bases.len(), 1_296); + + let mut allocator = NameAllocator::new(HashSet::new()); + let mut terminal = String::new(); + for base in &bases { + terminal = allocator.allocate(base, 128, '_'); + assert_eq!(terminal, *base); + for _ in 0..76 { + terminal = allocator.allocate(base, 128, '_'); + } + } + + let suffix_allocations = bases.len() * 76; + assert_eq!(allocator.candidate_probes(), suffix_allocations); + assert_eq!(allocator.used.len(), bases.len() + suffix_allocations); + assert_eq!(terminal, format!("{}_98497", &common_prefix[..122])); + } + + #[test] + fn snapshot_payload_limits_reject_each_bounded_payload_class() { + let mut artifact_snapshot = empty_snapshot(); + artifact_snapshot.artifacts.push(StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "large".to_owned(), + content: json!({ "value": "x".repeat(256) }), + }); + assert!(matches!( + prepare_snapshot_with_limits(artifact_snapshot, small_limits()), + Err(super::CatalogError::Validation { + code: "artifact_too_large", + .. + }) + )); + + let mut schema_snapshot = empty_snapshot(); + let mut schema_tool = staged_tool(); + schema_tool.input_schema = json!({ "value": "x".repeat(256) }); + schema_snapshot.tools.push(schema_tool); + assert!(matches!( + prepare_snapshot_with_limits(schema_snapshot, small_limits()), + Err(super::CatalogError::Validation { + code: "schema_too_large", + .. + }) + )); + + let mut definitions_snapshot = empty_snapshot(); + let mut definitions_tool = staged_tool(); + definitions_tool + .typescript_definitions + .insert("Large".to_owned(), "x".repeat(256)); + definitions_snapshot.tools.push(definitions_tool); + assert!(matches!( + prepare_snapshot_with_limits(definitions_snapshot, small_limits()), + Err(super::CatalogError::Validation { + code: "typescript_definitions_too_large", + .. + }) + )); + } + + #[test] + fn snapshot_payload_limits_reject_aggregate_serialized_bytes() { + let mut snapshot = empty_snapshot(); + for index in 0..3 { + snapshot.artifacts.push(StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: format!("artifact-{index}"), + content: json!({ "value": "x".repeat(90) }), + }); + } + let mut limits = small_limits(); + limits.artifact_count = 3; + assert!(matches!( + prepare_snapshot_with_limits(snapshot, limits), + Err(super::CatalogError::Validation { + code: "catalog_payload_too_large", + .. + }) + )); + } + + #[test] + fn snapshot_payload_budget_charges_typescript_and_text_copies() { + let mut typescript_snapshot = empty_snapshot(); + let mut typescript_tool = staged_tool(); + typescript_tool.input_typescript = Some("i".repeat(110)); + typescript_tool.output_typescript = Some("o".repeat(110)); + typescript_snapshot.tools.push(typescript_tool); + assert!(matches!( + prepare_snapshot_with_limits(typescript_snapshot, small_limits()), + Err(super::CatalogError::Validation { + code: "catalog_payload_too_large", + .. + }) + )); + + let mut key_and_name_snapshot = empty_snapshot(); + let mut key_and_name_tool = staged_tool(); + key_and_name_tool.stable_key = "k".repeat(80); + key_and_name_tool.preferred_name = "p".repeat(80); + key_and_name_tool.display_name = "d".repeat(80); + key_and_name_snapshot.tools.push(key_and_name_tool); + assert!(matches!( + prepare_snapshot_with_limits(key_and_name_snapshot, small_limits()), + Err(super::CatalogError::Validation { + code: "catalog_payload_too_large", + .. + }) + )); + + let mut description_snapshot = empty_snapshot(); + let mut description_tool = staged_tool(); + description_tool.description = Some("description".repeat(10)); + description_snapshot.tools.push(description_tool); + assert!(matches!( + prepare_snapshot_with_limits(description_snapshot, small_limits()), + Err(super::CatalogError::Validation { + code: "catalog_payload_too_large", + .. + }) + )); + } + + #[test] + fn snapshot_payload_limits_reject_preview_items_and_tiny_artifact_floods() { + let mut preview_snapshot = empty_snapshot(); + let mut preview_tool = staged_tool(); + preview_tool.input_typescript = Some("x".repeat(129)); + preview_snapshot.tools.push(preview_tool); + assert!(matches!( + prepare_snapshot_with_limits(preview_snapshot, small_limits()), + Err(super::CatalogError::Validation { + code: "typescript_too_large", + .. + }) + )); + + assert!(matches!( + super::validate_artifact_count(1_000_000, small_limits().artifact_count), + Err(super::CatalogError::Validation { + code: "catalog_too_many_artifacts", + .. + }) + )); + let mut tiny_artifacts = empty_snapshot(); + for index in 0..3 { + tiny_artifacts.artifacts.push(StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: format!("tiny-{index}"), + content: json!(null), + }); + } + assert!(matches!( + prepare_snapshot_with_limits(tiny_artifacts, small_limits()), + Err(super::CatalogError::Validation { + code: "catalog_too_many_artifacts", + .. + }) + )); + } + + #[tokio::test] + async fn audit_insert_compacts_oldest_rows_and_preserves_context_atomically() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("test database should open"); + sqlx::query( + "CREATE TABLE audit_events ( \ + id TEXT PRIMARY KEY NOT NULL, request_id TEXT, actor_admin_id INTEGER, \ + action TEXT NOT NULL, source_id TEXT, tool_id TEXT, target_path_snapshot TEXT, \ + metadata_json TEXT NOT NULL, created_at INTEGER NOT NULL)", + ) + .execute(&pool) + .await + .expect("audit table should be created"); + + for index in 1..=5 { + let request_id = format!("request-{index}"); + let mut transaction = pool.begin().await.expect("transaction should begin"); + insert_audit_with_limit( + &mut transaction, + AuditContext::admin(&request_id, 7), + &format!("action-{index}"), + Some("source-1"), + Some("tool-1"), + Some("tools.source.tool"), + json!({ "sequence": index }), + index, + 3, + ) + .await + .expect("bounded audit insertion should succeed"); + transaction + .commit() + .await + .expect("audit insertion and compaction should commit together"); + } + + let rows = sqlx::query_as::<_, (String, String, i64, String, String)>( + "SELECT action, request_id, actor_admin_id, target_path_snapshot, metadata_json \ + FROM audit_events ORDER BY created_at", + ) + .fetch_all(&pool) + .await + .expect("retained audit rows should read"); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].0, "action-3"); + assert_eq!(rows[2].0, "action-5"); + assert_eq!(rows[2].1, "request-5"); + assert_eq!(rows[2].2, 7); + assert_eq!(rows[2].3, "tools.source.tool"); + assert_eq!( + serde_json::from_str::(&rows[2].4) + .expect("retained metadata should be valid JSON"), + json!({ "sequence": 5 }) + ); + + let mut transaction = pool.begin().await.expect("transaction should begin"); + insert_audit_with_limit( + &mut transaction, + AuditContext::system(Some("request-6")), + "action-6", + Some("source-1"), + Some("tool-1"), + Some("tools.source.tool"), + json!({ "sequence": 6 }), + 6, + 3, + ) + .await + .expect("an uncommitted insertion should compact inside its transaction"); + let in_transaction = sqlx::query_scalar::<_, String>( + "SELECT group_concat(action, ',') FROM ( \ + SELECT action FROM audit_events ORDER BY created_at)", + ) + .fetch_one(&mut *transaction) + .await + .expect("transactional audit rows should read"); + assert_eq!(in_transaction, "action-4,action-5,action-6"); + transaction + .rollback() + .await + .expect("audit insertion and compaction should roll back together"); + let after_rollback = sqlx::query_scalar::<_, String>( + "SELECT group_concat(action, ',') FROM ( \ + SELECT action FROM audit_events ORDER BY created_at)", + ) + .fetch_one(&pool) + .await + .expect("rolled-back audit rows should read"); + assert_eq!(after_rollback, "action-3,action-4,action-5"); + } + + #[tokio::test] + async fn oversized_audit_metadata_is_rejected_without_inserting_a_row() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("test database should open"); + sqlx::query( + "CREATE TABLE audit_events ( \ + id TEXT PRIMARY KEY NOT NULL, request_id TEXT, actor_admin_id INTEGER, \ + action TEXT NOT NULL, source_id TEXT, tool_id TEXT, target_path_snapshot TEXT, \ + metadata_json TEXT NOT NULL, created_at INTEGER NOT NULL)", + ) + .execute(&pool) + .await + .expect("audit table should be created"); + let mut transaction = pool.begin().await.expect("transaction should begin"); + let error = insert_audit_with_limit( + &mut transaction, + AuditContext::system(Some("oversized-audit")), + "oversized", + None, + None, + None, + json!({ "value": "x".repeat(super::MAX_AUDIT_METADATA_BYTES) }), + 1, + 3, + ) + .await + .expect_err("oversized metadata should fail before insertion"); + assert!(matches!( + error, + CatalogError::Validation { + code: "audit_metadata_too_large", + .. + } + )); + transaction + .commit() + .await + .expect("empty transaction should commit"); + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM audit_events") + .fetch_one(&pool) + .await + .expect("audit count should read"); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn audit_retention_uses_insertion_order_when_the_wall_clock_moves_backward() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("test database should open"); + sqlx::query( + "CREATE TABLE audit_events ( \ + id TEXT PRIMARY KEY NOT NULL, request_id TEXT, actor_admin_id INTEGER, \ + action TEXT NOT NULL, source_id TEXT, tool_id TEXT, target_path_snapshot TEXT, \ + metadata_json TEXT NOT NULL, created_at INTEGER NOT NULL)", + ) + .execute(&pool) + .await + .expect("audit table should be created"); + + for (index, future_timestamp) in [10_000, 20_000, 30_000].into_iter().enumerate() { + let mut transaction = pool.begin().await.expect("transaction should begin"); + insert_audit_with_limit( + &mut transaction, + AuditContext::system(None), + &format!("future-{}", index + 1), + None, + None, + None, + json!({}), + future_timestamp, + 3, + ) + .await + .expect("future-dated audit should insert"); + transaction.commit().await.expect("audit should commit"); + } + + let mut transaction = pool.begin().await.expect("transaction should begin"); + insert_audit_with_limit( + &mut transaction, + AuditContext::system(Some("backward-clock-request")), + "new-after-clock-reset", + None, + None, + Some("tools.source.tool"), + json!({ "clock": "reset" }), + 1, + 3, + ) + .await + .expect("new audit should survive retention despite its lower timestamp"); + transaction.commit().await.expect("new audit should commit"); + + let retained = sqlx::query_as::<_, (String, i64)>( + "SELECT action, created_at FROM audit_events ORDER BY rowid", + ) + .fetch_all(&pool) + .await + .expect("retained audits should read"); + assert_eq!( + retained, + vec![ + ("future-2".to_owned(), 20_000), + ("future-3".to_owned(), 30_000), + ("new-after-clock-reset".to_owned(), 1), + ] + ); + } +} diff --git a/src/database.rs b/src/database.rs index 8cece61a9..af61f798e 100644 --- a/src/database.rs +++ b/src/database.rs @@ -196,6 +196,13 @@ async fn application_state_exists(pool: &SqlitePool) -> Result 0) \ OR EXISTS(SELECT 1 FROM instance_metadata WHERE key <> ?)", ) .bind(SENTINEL_KEY) diff --git a/src/lib.rs b/src/lib.rs index 25021f783..c33558a30 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ use thiserror::Error; use url::Url; mod api; +pub mod catalog; pub mod crypto; mod database; pub mod web_assets; @@ -113,18 +114,26 @@ pub struct ExecutorApp { router: Router, setup_token: Option, pool: SqlitePool, + catalog: catalog::CatalogStore, } impl ExecutorApp { pub async fn open(config: AppConfig) -> Result { let opened = database::Database::open(&config).await?; let pool = opened.database.pool.clone(); + let catalog = catalog::CatalogStore::new(pool.clone(), opened.database.keyring.clone()); let dummy_password_hash = crypto::hash_password("executor-dummy-login-password")?; - let router = api::router(opened.database, &config, dummy_password_hash); + let router = api::router( + opened.database, + catalog.clone(), + &config, + dummy_password_hash, + ); Ok(Self { router, setup_token: opened.setup_token, pool, + catalog, }) } @@ -139,6 +148,10 @@ impl ExecutorApp { pub fn pool(&self) -> &SqlitePool { &self.pool } + + pub fn catalog(&self) -> &catalog::CatalogStore { + &self.catalog + } } fn parse_public_origin(origin: &str) -> Result { diff --git a/tests/catalog.rs b/tests/catalog.rs new file mode 100644 index 000000000..ee42835e4 --- /dev/null +++ b/tests/catalog.rs @@ -0,0 +1,2582 @@ +use std::{collections::BTreeMap, fs, sync::Arc}; + +use axum::{ + Router, + body::Body, + http::{Method, Request, Response, StatusCode, header}, +}; +use executor::{ + AppConfig, DatabaseError, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, + ListToolsFilter, ModeProvenance, NewRequestLog, RequestOutcome, RequestSurface, SourceKind, + StagedArtifact, StagedTool, ToolMode, UpdateSource, + }, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const PASSWORD: &str = "correct-horse-battery-staple"; + +struct TestExecutor { + directory: TempDir, + app: Arc, +} + +struct AdminSession { + cookie: String, + csrf: String, +} + +impl TestExecutor { + async fn new() -> Self { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test Executor should open"); + Self { + directory, + app: Arc::new(app), + } + } + + fn router(&self) -> Router { + self.app.router() + } + + async fn source(&self, preferred_slug: &str) -> executor::catalog::SourceRecord { + self.app + .catalog() + .create_source( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: preferred_slug.to_owned(), + display_name: preferred_slug.to_owned(), + description: None, + configuration: Map::new(), + }, + AuditContext::system(None), + ) + .await + .expect("source should be created") + } + + async fn sync( + &self, + source_id: &str, + tools: Vec, + ) -> executor::catalog::CatalogSyncResult { + let source = self + .app + .catalog() + .source(source_id) + .await + .expect("source should exist"); + let credential_revision = self + .app + .catalog() + .credential(source_id) + .await + .expect("credential read should succeed") + .map(|credential| credential.revision); + self.app + .catalog() + .sync_catalog( + source_id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: credential_revision, + artifacts: Vec::new(), + tools, + }, + AuditContext::system(None), + ) + .await + .expect("catalog sync should succeed") + } + + async fn setup_admin(&self) { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": self.app.setup_token().expect("setup token should exist"), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + } + + async fn login(&self) -> AdminSession { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = response_cookie_header(&response); + let body = response_json(response).await; + let csrf = body["csrfToken"] + .as_str() + .expect("login should reveal CSRF") + .to_owned(); + AdminSession { cookie, csrf } + } + + async fn create_api_token(&self, admin: &AdminSession, name: &str) -> String { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": name }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response_json(response).await["token"] + .as_str() + .expect("token should be revealed") + .to_owned() + } +} + +fn staged(stable_key: &str, preferred_name: &str, mode: ToolMode) -> StagedTool { + StagedTool { + stable_key: stable_key.to_owned(), + preferred_name: preferred_name.to_owned(), + display_name: preferred_name.to_owned(), + description: Some(format!("Operate on {preferred_name}")), + input_schema: json!({ "type": "object" }), + output_schema: Some(json!({ "type": "object" })), + input_typescript: Some("{ id: string }".to_owned()), + output_typescript: Some("{ ok: boolean }".to_owned()), + typescript_definitions: BTreeMap::new(), + intrinsic_mode: mode, + } +} + +#[tokio::test] +async fn catalog_migration_is_strict_and_enforces_foreign_keys_and_constraints() { + let executor = TestExecutor::new().await; + let strict_tables = + sqlx::query_as::<_, (String, String, String, i64, i64, i64)>("PRAGMA table_list") + .fetch_all(executor.app.pool()) + .await + .expect("table list should be readable") + .into_iter() + .filter(|(_, name, _, _, _, _)| { + matches!( + name.as_str(), + "catalog_state" + | "sources" + | "source_credentials" + | "source_artifacts" + | "tools" + | "request_logs" + | "audit_events" + ) + }) + .collect::>(); + assert_eq!(strict_tables.len(), 7); + assert!(strict_tables.iter().all(|row| row.5 == 1)); + + let foreign_key_error = sqlx::query( + "INSERT INTO source_credentials \ + (source_id, schema_version, payload_ciphertext, revision, created_at, updated_at) \ + VALUES ('missing', 1, X'01', 0, 1, 1)", + ) + .execute(executor.app.pool()) + .await + .expect_err("credential without source must fail"); + assert!(foreign_key_error.as_database_error().is_some()); + + let invalid_kind = sqlx::query( + "INSERT INTO sources \ + (id, kind, slug, display_name, configuration_json, created_at, updated_at) \ + VALUES ('bad', 'unknown', 'bad', 'Bad', '{}', 1, 1)", + ) + .execute(executor.app.pool()) + .await + .expect_err("closed source kind must reject unknown values"); + assert!(invalid_kind.as_database_error().is_some()); + + let reserved_slug = sqlx::query( + "INSERT INTO sources \ + (id, kind, slug, display_name, configuration_json, created_at, updated_at) \ + VALUES ('reserved', 'openapi', 'tools', 'Reserved', '{}', 1, 1)", + ) + .execute(executor.app.pool()) + .await + .expect_err("reserved source roots must be rejected by SQLite"); + assert!(reserved_slug.as_database_error().is_some()); + + let source = executor.source("strict").await; + let strict_error = sqlx::query("UPDATE sources SET display_name = ? WHERE id = ?") + .bind(vec![0_u8, 1_u8]) + .bind(source.id) + .execute(executor.app.pool()) + .await + .expect_err("STRICT text column must reject blobs"); + assert!(strict_error.as_database_error().is_some()); + + let foreign_key_check = + sqlx::query_as::<_, (String, i64, String, i64)>("PRAGMA foreign_key_check") + .fetch_all(executor.app.pool()) + .await + .expect("foreign key check should run"); + assert!(foreign_key_check.is_empty()); +} + +#[tokio::test] +async fn source_and_tool_names_are_collision_safe_and_order_independent() { + let executor = TestExecutor::new().await; + let first = executor.source("Git Hub").await; + let second = executor.source("Git-Hub").await; + assert_eq!(first.slug, "git_hub"); + assert_eq!(second.slug, "git_hub_2"); + for reserved in ["tools", "search", "describe", "executor"] { + let source = executor.source(reserved).await; + assert_eq!(source.slug, format!("{reserved}_2")); + } + + let updated = executor + .app + .catalog() + .update_source( + &first.id, + UpdateSource { + display_name: "GitHub API".to_owned(), + description: Some("Repository operations".to_owned()), + configuration: Map::from_iter([( + "baseUrl".to_owned(), + json!("https://api.example.invalid"), + )]), + expected_revision: first.revision, + }, + AuditContext::system(None), + ) + .await + .expect("source should update optimistically"); + assert_eq!(updated.slug, first.slug); + assert_eq!(updated.display_name, "GitHub API"); + assert!(matches!( + executor + .app + .catalog() + .update_source( + &first.id, + UpdateSource { + display_name: "Stale".to_owned(), + description: None, + configuration: Map::new(), + expected_revision: first.revision, + }, + AuditContext::system(None), + ) + .await, + Err(CatalogError::RevisionConflict { + scope: "source", + .. + }) + )); + + executor + .sync( + &first.id, + vec![ + staged("z-key", "Get Repo", ToolMode::Enabled), + staged("a-key", "Get-Repo", ToolMode::Enabled), + ], + ) + .await; + let first_tools = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(first.id.clone()), + limit: 100, + ..Default::default() + }) + .await + .expect("tools should list") + .items; + let names_by_key = first_tools + .iter() + .map(|tool| (tool.stable_key.as_str(), tool.local_name.as_str())) + .collect::>(); + assert_eq!(names_by_key["a-key"], "get_repo"); + assert_eq!(names_by_key["z-key"], "get_repo_2"); + assert!(first_tools.iter().all(|tool| { + tool.callable_path == format!("tools.{}.{}", first.slug, tool.local_name) + && tool.sandbox_path == format!("{}.{}", first.slug, tool.local_name) + })); +} + +#[tokio::test] +async fn refresh_is_atomic_and_preserves_overrides_through_tombstones() { + let executor = TestExecutor::new().await; + let source = executor.source("catalog").await; + executor + .sync( + &source.id, + vec![ + staged("alpha", "Run", ToolMode::Enabled), + staged("beta", "Run", ToolMode::Ask), + ], + ) + .await; + let tools = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 100, + ..Default::default() + }) + .await + .expect("tools should list") + .items; + let beta = tools + .iter() + .find(|tool| tool.stable_key == "beta") + .expect("beta should exist") + .clone(); + let beta = executor + .app + .catalog() + .set_tool_mode( + &beta.id, + Some(ToolMode::Disabled), + beta.revision, + AuditContext::system(None), + ) + .await + .expect("override should update"); + let before_failure = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let duplicate = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: before_failure.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![ + staged("same", "One", ToolMode::Enabled), + staged("same", "Two", ToolMode::Enabled), + ], + }, + AuditContext::system(None), + ) + .await + .expect_err("duplicate staging must fail before the transaction"); + assert!(matches!( + duplicate, + CatalogError::Validation { + code: "duplicate_stable_key", + .. + } + )); + let after_failure = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + assert_eq!( + after_failure.catalog_revision, + before_failure.catalog_revision + ); + + executor + .sync(&source.id, vec![staged("alpha", "Run", ToolMode::Enabled)]) + .await; + let tombstoned = executor + .app + .catalog() + .tool(&beta.id) + .await + .expect("tombstoned tool should remain in admin catalog"); + assert!(!tombstoned.present); + assert_eq!(tombstoned.mode_override, Some(ToolMode::Disabled)); + + executor + .sync( + &source.id, + vec![ + staged("beta", "Different Display Name", ToolMode::Ask), + staged("alpha", "Run", ToolMode::Enabled), + ], + ) + .await; + let restored = executor + .app + .catalog() + .tool(&beta.id) + .await + .expect("restored tool should retain identity"); + assert!(restored.present); + assert_eq!(restored.id, beta.id); + assert_eq!(restored.local_name, beta.local_name); + assert_eq!(restored.mode_override, Some(ToolMode::Disabled)); +} + +#[tokio::test] +async fn refresh_rejects_oversized_serialized_schema_payloads_before_writing() { + let executor = TestExecutor::new().await; + let source = executor.source("payload-limit").await; + let mut oversized = staged("large", "Large", ToolMode::Enabled); + oversized.input_schema = json!({ "value": "x".repeat(2 * 1024 * 1024) }); + let error = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![oversized], + }, + AuditContext::system(None), + ) + .await + .expect_err("oversized schema must be rejected before refresh"); + assert!(matches!( + error, + CatalogError::Validation { + code: "schema_too_large", + .. + } + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tools WHERE source_id = ?") + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("tool count should read"), + 0 + ); +} + +#[tokio::test] +async fn refresh_cas_and_writer_lock_prevent_stale_or_partial_catalogs() { + let executor = TestExecutor::new().await; + let source = executor.source("refresh-basis").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "first" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("credential should store"); + let first_credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let basis = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "second" }), + }, + Some(first_credential.revision), + AuditContext::system(None), + ) + .await + .expect("credential should rotate"); + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let stale_credential = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: Some(credential.revision - 1), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "root".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![staged("new", "New", ToolMode::Enabled)], + }, + AuditContext::system(None), + ) + .await; + assert!(matches!( + stale_credential, + Err(CatalogError::RevisionConflict { + scope: "credential", + .. + }) + )); + assert_eq!( + executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .catalog_revision, + basis.catalog_revision + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM source_artifacts") + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 0 + ); + + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let snapshot = CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: Some(credential.revision), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "root".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![staged("new", "New", ToolMode::Enabled)], + }; + let first_store = executor.app.catalog().clone(); + let second_store = executor.app.catalog().clone(); + let first = first_store.sync_catalog( + &source.id, + snapshot.clone(), + AuditContext::system(Some("refresh-race-1")), + ); + let second = second_store.sync_catalog( + &source.id, + snapshot, + AuditContext::system(Some("refresh-race-2")), + ); + let (first, second) = tokio::join!(first, second); + let outcomes = [first, second]; + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!( + outcome, + Err(CatalogError::RevisionConflict { + scope: "source", + .. + }) + )) + .count(), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM source_artifacts") + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 1 + ); +} + +#[tokio::test] +async fn credential_writes_require_absence_or_the_current_revision() { + let executor = TestExecutor::new().await; + let source = executor.source("credential-cas").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "first" }), + }, + None, + AuditContext::system(Some("credential-create")), + ) + .await + .expect("an absent credential should be created"); + let first = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(first.revision, 0); + + let source_after_create = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_after_create = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let duplicate_create = executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "duplicate" }), + }, + None, + AuditContext::system(Some("credential-duplicate")), + ) + .await; + assert!(matches!( + duplicate_create, + Err(CatalogError::RevisionConflict { + scope: "credential", + expected: -1, + actual: 0, + }) + )); + assert_eq!( + executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .revision, + source_after_create.revision + ); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_after_create + ); + + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "newer" }), + }, + Some(first.revision), + AuditContext::system(Some("credential-rotate")), + ) + .await + .expect("the current credential revision should rotate"); + let source_after_rotate = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_after_rotate = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let audit_after_rotate = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events \ + WHERE action = 'source.credential_changed' AND source_id = ?", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("credential audit count should read"); + assert_eq!(audit_after_rotate, 2); + + let stale_rotate = executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "stale" }), + }, + Some(first.revision), + AuditContext::system(Some("credential-stale")), + ) + .await; + assert!(matches!( + stale_rotate, + Err(CatalogError::RevisionConflict { + scope: "credential", + expected: 0, + actual: 1, + }) + )); + let stored = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(stored.revision, 1); + assert_eq!(stored.credential.payload["token"], "newer"); + assert_eq!( + executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .revision, + source_after_rotate.revision + ); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_after_rotate + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events \ + WHERE action = 'source.credential_changed' AND source_id = ?", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("credential audit count should read"), + audit_after_rotate + ); +} + +#[tokio::test] +async fn refresh_audits_preserve_correlation_and_explicit_actor() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let source = executor.source("refresh-audit").await; + executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("run", "Run", ToolMode::Enabled)], + }, + AuditContext::system(Some("refresh-job-1")), + ) + .await + .expect("background refresh should commit"); + let system_audit = sqlx::query_as::<_, (Option, Option)>( + "SELECT request_id, actor_admin_id FROM audit_events \ + WHERE action = 'source.catalog_refreshed' AND request_id = 'refresh-job-1'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("system refresh audit should exist"); + assert_eq!(system_audit, (Some("refresh-job-1".to_owned()), None)); + + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("run", "Run", ToolMode::Enabled)], + }, + AuditContext::admin("admin-request-1", 1), + ) + .await + .expect("administrator refresh should commit"); + let admin_audit = sqlx::query_as::<_, (Option, Option)>( + "SELECT request_id, actor_admin_id FROM audit_events \ + WHERE action = 'source.catalog_refreshed' AND request_id = 'admin-request-1'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("administrator refresh audit should exist"); + assert_eq!(admin_audit, (Some("admin-request-1".to_owned()), Some(1))); +} + +#[tokio::test] +async fn effective_modes_follow_precedence_and_gateway_visibility_rules() { + let executor = TestExecutor::new().await; + let source = executor.source("modes").await; + executor + .sync(&source.id, vec![staged("review", "Review", ToolMode::Ask)]) + .await; + let tool = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + assert_eq!(tool.effective_mode.mode, ToolMode::Ask); + assert_eq!(tool.effective_mode.provenance, ModeProvenance::Intrinsic); + let discovery = executor + .app + .catalog() + .search_tools("review", None, 10, 0) + .await + .expect("search should work"); + assert_eq!(discovery.total, 1); + assert!(discovery.items[0].requires_approval); + assert_eq!(discovery.items[0].effective_mode, ToolMode::Ask); + let ask_lookup = executor + .app + .catalog() + .guard_invocation(&tool.callable_path) + .await + .expect("Ask tool should remain callable through approval"); + assert!(ask_lookup.requires_approval); + + let source = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .app + .catalog() + .set_source_mode( + &source.id, + Some(ToolMode::Disabled), + source.revision, + AuditContext::system(None), + ) + .await + .expect("source override should update"); + let disabled = executor + .app + .catalog() + .tool(&tool.id) + .await + .expect("admin catalog should retain disabled tool"); + assert_eq!(disabled.effective_mode.mode, ToolMode::Disabled); + assert_eq!( + disabled.effective_mode.provenance, + ModeProvenance::SourceOverride + ); + assert_eq!( + executor + .app + .catalog() + .search_tools("review", None, 10, 0) + .await + .expect("search should work") + .total, + 0 + ); + assert!(matches!( + executor + .app + .catalog() + .describe_tool(&tool.sandbox_path) + .await, + Err(CatalogError::ToolNotFound { .. }) + )); + assert!(matches!( + executor + .app + .catalog() + .guard_invocation(&tool.sandbox_path) + .await, + Err(CatalogError::ToolDisabled { .. }) + )); + + let override_enabled = executor + .app + .catalog() + .set_tool_mode( + &tool.id, + Some(ToolMode::Enabled), + disabled.revision, + AuditContext::system(None), + ) + .await + .expect("tool override should update"); + assert_eq!(override_enabled.effective_mode.mode, ToolMode::Enabled); + assert_eq!( + override_enabled.effective_mode.provenance, + ModeProvenance::ToolOverride + ); + let reset = executor + .app + .catalog() + .set_tool_mode( + &tool.id, + None, + override_enabled.revision, + AuditContext::system(None), + ) + .await + .expect("tool override should reset"); + assert_eq!(reset.effective_mode.mode, ToolMode::Disabled); +} + +#[tokio::test] +async fn lexical_search_ranks_filters_and_paginates_like_the_typescript_runtime() { + let executor = TestExecutor::new().await; + let github = executor.source("github").await; + let stripe = executor.source("stripe").await; + let go = executor.source("go").await; + executor + .sync( + &github.id, + vec![ + staged("create-issue", "createIssue", ToolMode::Ask), + staged("list-issues", "listIssues", ToolMode::Enabled), + ], + ) + .await; + executor + .sync(&go.id, vec![staged("ping", "ping", ToolMode::Enabled)]) + .await; + let mut camel_description = staged("customer-ledger", "archive", ToolMode::Enabled); + camel_description.description = Some("CustomerLedger workflow".to_owned()); + executor + .sync( + &stripe.id, + vec![ + staged("create-invoice", "createInvoice", ToolMode::Enabled), + camel_description, + ], + ) + .await; + + let exact = executor + .app + .catalog() + .search_tools("create issue", None, 10, 0) + .await + .expect("search should work"); + assert_eq!(exact.total, 1); + assert_eq!(exact.items[0].path, "github.create_issue"); + + let first_page = executor + .app + .catalog() + .search_tools("create", None, 1, 0) + .await + .expect("search should work"); + assert_eq!(first_page.total, 2); + assert!(first_page.has_more); + assert_eq!(first_page.next_offset, Some(1)); + let second_page = executor + .app + .catalog() + .search_tools("create", None, 1, 1) + .await + .expect("search should work"); + assert_eq!(second_page.items.len(), 1); + assert!(!second_page.has_more); + assert_ne!(first_page.items[0].path, second_page.items[0].path); + + let github_only = executor + .app + .catalog() + .search_tools("create", Some("github"), 10, 0) + .await + .expect("namespace search should work"); + assert_eq!(github_only.total, 1); + assert_eq!(github_only.items[0].integration, "github"); + let camel_description = executor + .app + .catalog() + .search_tools("ledger", None, 10, 0) + .await + .expect("normalized description search should work"); + assert_eq!(camel_description.total, 1); + assert_eq!(camel_description.items[0].path, "stripe.archive"); + for query in ["hub", "githubs", "it"] { + let legacy_match = executor + .app + .catalog() + .search_tools(query, None, 10, 0) + .await + .expect("legacy substring and reverse-prefix search should work"); + assert!( + legacy_match + .items + .iter() + .any(|item| item.integration == "github"), + "{query} should reach the indexed github candidate" + ); + } + let short_reverse_prefix = executor + .app + .catalog() + .search_tools("google", None, 10, 0) + .await + .expect("short reverse-prefix search should work"); + assert!( + short_reverse_prefix + .items + .iter() + .any(|item| item.integration == "go") + ); + let empty = executor + .app + .catalog() + .search_tools("---", None, 10, 0) + .await + .expect("punctuation search should be empty"); + assert_eq!(empty.total, 0); +} + +#[tokio::test] +async fn gateway_search_rejects_unbounded_direct_store_queries_before_index_work() { + let executor = TestExecutor::new().await; + let excessive_bytes = "token ".repeat(100_000); + for (query, namespace) in [ + (excessive_bytes.as_str(), None), + (&"x".repeat(257), None), + (&"x ".repeat(65), None), + ("safe", Some(excessive_bytes.as_str())), + ] { + assert!(matches!( + executor + .app + .catalog() + .search_tools(query, namespace, 10, 0) + .await, + Err(CatalogError::Validation { + code: "invalid_search_query", + .. + }) + )); + } +} + +#[tokio::test] +async fn gateway_search_bounds_candidates_and_filters_historical_rows_before_ranking() { + let executor = TestExecutor::new().await; + let source = executor.source("search-scale").await; + executor + .sync( + &source.id, + vec![staged("active", "Needle", ToolMode::Enabled)], + ) + .await; + + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 10000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('historical-%05d', value), ?, printf('historical-key-%05d', value), \ + printf('needle_historical_%05d', value), 'Historical Needle', \ + 'needle historical entry', '{}', '{}', \ + CASE WHEN value <= 5000 THEN 'enabled' ELSE 'disabled' END, \ + CASE WHEN value <= 5000 THEN 0 ELSE 1 END, 0, 1, 1, 1, \ + CASE WHEN value <= 5000 THEN 1 ELSE NULL END \ + FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("large tombstone history should seed"); + sqlx::query( + "INSERT INTO tool_search \ + (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ + SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ + replace(tools.local_name, '_', ' '), tools.description, \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.id LIKE 'historical-%'", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("historical search entries should seed"); + + let historical = executor + .app + .catalog() + .search_tools("needle", None, 10, 0) + .await + .expect("search should ignore historical candidates in SQL"); + assert_eq!(historical.total, 1); + assert_eq!(historical.items[0].path, "search_scale.needle"); + + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 5000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('bounded-%05d', value), ?, printf('bounded-key-%05d', value), \ + printf('common_%05d', value), 'Common', 'common candidate', '{}', '{}', \ + 'enabled', 1, 0, 1, 1, 1, NULL FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("large active catalog should seed"); + sqlx::query( + "INSERT INTO tool_search \ + (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ + SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ + replace(tools.local_name, '_', ' '), tools.description, \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.id LIKE 'bounded-%'", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("active search entries should seed"); + let bounded = executor + .app + .catalog() + .search_tools("common", None, 10, 0) + .await + .expect("large search should remain bounded"); + assert_eq!(bounded.items.len(), 10); + assert_eq!(bounded.total, 4096); + assert!(bounded.has_more); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tools WHERE source_id = ? AND present = 1 \ + AND description = 'common candidate'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("active candidate count should read"), + 5000 + ); +} + +#[tokio::test] +async fn admin_tool_listing_filters_counts_and_pages_in_sql() { + let executor = TestExecutor::new().await; + let alpha = executor.source("alpha-list").await; + let beta = executor.source("beta-list").await; + executor + .sync( + &alpha.id, + vec![ + staged("keep", "Alpha Search", ToolMode::Enabled), + staged("gone", "Archived Match", ToolMode::Enabled), + staged("ask", "Review Match", ToolMode::Ask), + ], + ) + .await; + executor + .sync( + &beta.id, + vec![staged("other", "Alpha Other", ToolMode::Enabled)], + ) + .await; + executor + .sync( + &alpha.id, + vec![ + staged("keep", "Alpha Search", ToolMode::Enabled), + staged("ask", "Review Match", ToolMode::Ask), + ], + ) + .await; + + let active = executor + .app + .catalog() + .list_tools(ListToolsFilter { + limit: 100, + ..Default::default() + }) + .await + .expect("active tools should list"); + assert_eq!(active.total, 3); + assert!(active.items.iter().all(|tool| tool.present)); + + let source_query = executor + .app + .catalog() + .list_tools(ListToolsFilter { + query: Some("match".to_owned()), + source_id: Some(alpha.id.clone()), + limit: 100, + ..Default::default() + }) + .await + .expect("source and query filters should compose"); + assert_eq!(source_query.total, 1); + assert_eq!(source_query.items[0].stable_key, "ask"); + + let with_tombstones = executor + .app + .catalog() + .list_tools(ListToolsFilter { + query: Some("match".to_owned()), + source_id: Some(alpha.id.clone()), + include_tombstoned: true, + limit: 100, + ..Default::default() + }) + .await + .expect("tombstones should be included on request"); + assert_eq!(with_tombstones.total, 2); + assert_eq!( + with_tombstones + .items + .iter() + .filter(|tool| !tool.present) + .count(), + 1 + ); + + let ask_only = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(alpha.id), + effective_mode: Some(ToolMode::Ask), + include_tombstoned: true, + limit: 100, + ..Default::default() + }) + .await + .expect("effective mode should filter in SQL"); + assert_eq!(ask_only.total, 1); + assert_eq!(ask_only.items[0].stable_key, "ask"); + + let first_page = executor + .app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + limit: 2, + offset: 0, + ..Default::default() + }) + .await + .expect("first page should list"); + let second_page = executor + .app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + limit: 2, + offset: 2, + ..Default::default() + }) + .await + .expect("second page should list"); + assert_eq!(first_page.total, 4); + assert_eq!(second_page.total, 4); + assert!(first_page.has_more); + assert_eq!(first_page.next_offset, Some(2)); + assert!(!second_page.has_more); + let mut combined_paths = first_page + .items + .iter() + .chain(&second_page.items) + .map(|tool| tool.callable_path.clone()) + .collect::>(); + let original_paths = combined_paths.clone(); + combined_paths.sort(); + assert_eq!(original_paths, combined_paths); + combined_paths.dedup(); + assert_eq!(combined_paths.len(), 4); +} + +#[tokio::test] +async fn catalog_churn_rejects_tombstone_history_overflow_atomically() { + let executor = TestExecutor::new().await; + let source = executor.source("history-ceiling").await; + executor + .sync( + &source.id, + vec![staged("current", "Current", ToolMode::Enabled)], + ) + .await; + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 25000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('retained-history-%05d', value), ?, printf('retained-key-%05d', value), \ + printf('retained_%05d', value), 'Retained', NULL, '{}', '{}', 'enabled', \ + 0, 0, 1, 1, 1, 1 FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("tombstone ceiling fixture should seed"); + let before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let error = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: before.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "must-not-write".to_owned(), + content: json!({ "atomic": true }), + }], + tools: vec![staged("new-key", "New Tool", ToolMode::Enabled)], + }, + AuditContext::system(None), + ) + .await + .expect_err("churn beyond retained tombstone ceiling must fail"); + assert!(matches!( + error, + CatalogError::Validation { + code: "catalog_too_large", + .. + } + )); + let after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after.revision, before.revision); + assert_eq!(after.catalog_revision, before.catalog_revision); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tools WHERE source_id = ? AND stable_key = 'current' \ + AND present = 1", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("current tool state should read"), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tools WHERE source_id = ? AND stable_key = 'new-key'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("new tool state should read"), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM source_artifacts WHERE source_id = ? \ + AND stable_key = 'must-not-write'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("artifact state should read"), + 0 + ); +} + +#[tokio::test] +async fn optimistic_and_bulk_mode_changes_are_atomic() { + let executor = TestExecutor::new().await; + let source = executor.source("bulk").await; + executor + .sync( + &source.id, + vec![ + staged("one", "One", ToolMode::Enabled), + staged("two", "Two", ToolMode::Enabled), + ], + ) + .await; + let tools = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tools should list") + .items; + let first = &tools[0]; + executor + .app + .catalog() + .set_tool_mode( + &first.id, + Some(ToolMode::Ask), + first.revision, + AuditContext::system(None), + ) + .await + .expect("first optimistic update should work"); + assert!(matches!( + executor + .app + .catalog() + .set_tool_mode( + &first.id, + Some(ToolMode::Disabled), + first.revision, + AuditContext::system(None), + ) + .await, + Err(CatalogError::RevisionConflict { scope: "tool", .. }) + )); + + let global_revision = executor + .app + .catalog() + .global_revision() + .await + .expect("revision should read"); + let invalid_bulk = executor + .app + .catalog() + .bulk_set_tool_modes( + &[tools[1].id.clone(), "missing".to_owned()], + Some(ToolMode::Disabled), + global_revision, + AuditContext::system(None), + ) + .await; + assert!(matches!( + invalid_bulk, + Err(CatalogError::NotFound { entity: "tool" }) + )); + assert_eq!( + executor + .app + .catalog() + .tool(&tools[1].id) + .await + .expect("tool should remain") + .mode_override, + None + ); + + let result = executor + .app + .catalog() + .bulk_set_tool_modes( + &tools.iter().map(|tool| tool.id.clone()).collect::>(), + Some(ToolMode::Disabled), + global_revision, + AuditContext::system(Some("explicit-bulk-audit")), + ) + .await + .expect("valid bulk update should commit"); + assert_eq!(result.updated_count, 2); + let audit_metadata = sqlx::query_scalar::<_, String>( + "SELECT metadata_json FROM audit_events \ + WHERE request_id = 'explicit-bulk-audit' AND action = 'tool.mode_bulk_changed'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("explicit bulk audit metadata should exist"); + let audit_metadata: Value = + serde_json::from_str(&audit_metadata).expect("audit metadata should be valid JSON"); + let mut expected_selection = tools + .iter() + .map(|tool| { + json!({ + "toolId": tool.id, + "path": tool.callable_path + }) + }) + .collect::>(); + expected_selection + .sort_by(|left, right| left["toolId"].as_str().cmp(&right["toolId"].as_str())); + assert_eq!( + audit_metadata["selectedTools"], + Value::Array(expected_selection) + ); + for tool in &tools { + assert_eq!( + executor + .app + .catalog() + .tool(&tool.id) + .await + .expect("tool should exist") + .mode_override, + Some(ToolMode::Disabled) + ); + } + + let stale_source = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + assert!(matches!( + executor + .app + .catalog() + .bulk_set_source_tool_modes( + &source.id, + Some(ToolMode::Enabled), + stale_source.revision - 1, + AuditContext::system(None), + ) + .await, + Err(CatalogError::RevisionConflict { + scope: "source", + .. + }) + )); +} + +#[tokio::test] +async fn catalog_writes_wait_for_non_catalog_writers_without_busy_snapshots() { + let executor = TestExecutor::new().await; + let source = executor.source("writer-race").await; + let mut outside_write = executor + .app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("outside writer should reserve SQLite"); + sqlx::query( + "INSERT INTO request_logs \ + (request_id, surface, path_snapshot, outcome, duration_ms, created_at) \ + VALUES ('blocking-write', 'gateway', 'tools.search', 'succeeded', 1, 1)", + ) + .execute(&mut *outside_write) + .await + .expect("outside writer should hold an uncommitted write"); + + let catalog = executor.app.catalog().clone(); + let source_id = source.id.clone(); + let update = tokio::spawn(async move { + catalog + .set_source_mode( + &source_id, + Some(ToolMode::Ask), + source.revision, + AuditContext::system(None), + ) + .await + }); + tokio::task::yield_now().await; + assert!(!update.is_finished()); + outside_write + .commit() + .await + .expect("outside writer should commit"); + let updated = tokio::time::timeout(std::time::Duration::from_secs(2), update) + .await + .expect("catalog writer should resume before the busy timeout") + .expect("catalog writer task should not panic") + .expect("catalog writer should succeed"); + assert_eq!(updated.mode_override, Some(ToolMode::Ask)); +} + +#[tokio::test] +async fn credentials_are_encrypted_bound_to_the_source_and_never_logged() { + let executor = TestExecutor::new().await; + let first = executor.source("credential-one").await; + let second = executor.source("credential-two").await; + let secret = "catalog-super-secret-value"; + executor + .app + .catalog() + .put_credential( + &first.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": secret, "header": "Authorization" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("credential should be stored"); + let stored = executor + .app + .catalog() + .credential(&first.id) + .await + .expect("credential should decrypt") + .expect("credential should exist"); + assert_eq!(stored.credential.payload["token"], secret); + + let ciphertext = sqlx::query_scalar::<_, Vec>( + "SELECT payload_ciphertext FROM source_credentials WHERE source_id = ?", + ) + .bind(&first.id) + .fetch_one(executor.app.pool()) + .await + .expect("ciphertext should exist"); + assert!(!String::from_utf8_lossy(&ciphertext).contains(secret)); + sqlx::query( + "INSERT INTO source_credentials \ + (source_id, schema_version, payload_ciphertext, revision, created_at, updated_at) \ + VALUES (?, 1, ?, 0, 1, 1)", + ) + .bind(&second.id) + .bind(ciphertext) + .execute(executor.app.pool()) + .await + .expect("test should copy ciphertext to another record"); + assert!(matches!( + executor.app.catalog().credential(&second.id).await, + Err(CatalogError::Crypto(_)) + )); + + let database_bytes = fs::read(executor.directory.path().join("executor.db")) + .expect("database should be readable"); + assert!(!String::from_utf8_lossy(&database_bytes).contains(secret)); + let leaked_logs = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs WHERE path_snapshot LIKE '%' || ? || '%' \ + OR error_code LIKE '%' || ? || '%'", + ) + .bind(secret) + .bind(secret) + .fetch_one(executor.app.pool()) + .await + .expect("logs should be queryable"); + assert_eq!(leaked_logs, 0); +} + +#[tokio::test] +async fn request_logs_are_redacted_paginated_and_survive_catalog_deletion() { + let executor = TestExecutor::new().await; + let source = executor.source("history").await; + executor + .sync(&source.id, vec![staged("run", "Run", ToolMode::Enabled)]) + .await; + let tool = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tools should list") + .items + .remove(0); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "request-2".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: Some(source.id.clone()), + tool_id: Some(tool.id.clone()), + path_snapshot: Some(tool.callable_path.clone()), + outcome: RequestOutcome::Failed, + error_code: Some("upstream_unavailable".to_owned()), + duration_ms: 12, + approval_id: None, + created_at: 2, + }) + .await + .expect("log should store"); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "request-1".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 1, + }) + .await + .expect("second log should store"); + let first_page = executor + .app + .catalog() + .list_request_logs(None, 1) + .await + .expect("logs should list"); + assert_eq!(first_page.items[0].request_id, "request-2"); + let second_page = executor + .app + .catalog() + .list_request_logs(first_page.next_cursor.as_deref(), 1) + .await + .expect("cursor should continue"); + assert_eq!(second_page.items[0].request_id, "request-1"); + + let columns = sqlx::query_as::<_, (i64, String, String, i64, Option, i64)>( + "PRAGMA table_info(request_logs)", + ) + .fetch_all(executor.app.pool()) + .await + .expect("log columns should be readable") + .into_iter() + .map(|row| row.1) + .collect::>(); + for forbidden in ["headers", "credentials", "arguments", "results", "body"] { + assert!(!columns.iter().any(|column| column.contains(forbidden))); + } + + executor + .app + .catalog() + .delete_source(&source.id, AuditContext::system(None)) + .await + .expect("source should delete"); + let retained = executor + .app + .catalog() + .request_log("request-2") + .await + .expect("history should survive"); + assert_eq!(retained.source_id, None); + assert_eq!(retained.tool_id, None); + assert_eq!( + retained.path_snapshot.as_deref(), + Some(tool.callable_path.as_str()) + ); + let audit_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM audit_events") + .fetch_one(executor.app.pool()) + .await + .expect("audit count should read"); + assert!(audit_count > 0); + let audit_without_snapshot = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events \ + WHERE action LIKE 'source.%' AND target_path_snapshot IS NULL", + ) + .fetch_one(executor.app.pool()) + .await + .expect("source audit snapshots should be queryable"); + assert_eq!(audit_without_snapshot, 0); + + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "late-request".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: Some(source.id), + tool_id: Some(tool.id), + path_snapshot: Some(tool.callable_path), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 2, + approval_id: None, + created_at: 3, + }) + .await + .expect("late logs should null stale foreign keys"); + let late = executor + .app + .catalog() + .request_log("late-request") + .await + .expect("late log should exist"); + assert_eq!(late.source_id, None); + assert_eq!(late.tool_id, None); +} + +#[tokio::test] +async fn request_logs_prune_atomically_and_apply_bounded_writer_backpressure() { + let executor = TestExecutor::new().await; + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 10000) \ + INSERT INTO request_logs \ + (request_id, surface, path_snapshot, outcome, duration_ms, created_at) \ + SELECT printf('retained-%05d', value), 'gateway', 'tools.search', \ + 'succeeded', 1, value FROM sequence", + ) + .execute(executor.app.pool()) + .await + .expect("retention boundary should seed"); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "newest-retained".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10001, + }) + .await + .expect("insert and retention prune should commit together"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM request_logs") + .fetch_one(executor.app.pool()) + .await + .expect("retained count should read"), + 10000 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs WHERE request_id = 'retained-00001'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("oldest retention state should read"), + 0 + ); + executor + .app + .catalog() + .request_log("newest-retained") + .await + .expect("new log should survive pruning"); + + let outside_writer = executor + .app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("outside writer should reserve SQLite"); + let blocked_catalog = executor.app.catalog().clone(); + let blocked = tokio::spawn(async move { + blocked_catalog + .record_request(NewRequestLog { + request_id: "blocked-log".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10002, + }) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let overloaded = executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "overloaded-log".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10003, + }) + .await; + assert!(matches!( + overloaded, + Err(CatalogError::Validation { + code: "request_log_backpressure", + .. + }) + )); + assert!(!blocked.is_finished()); + outside_writer + .commit() + .await + .expect("outside writer should commit"); + tokio::time::timeout(std::time::Duration::from_secs(2), blocked) + .await + .expect("the single logger should resume after contention clears") + .expect("blocked logger task should not panic") + .expect("blocked log should persist after contention clears"); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "after-backpressure".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10004, + }) + .await + .expect("logger should recover after database contention"); +} + +#[tokio::test] +async fn catalog_state_keeps_missing_boot_sentinels_fail_closed() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + app.catalog() + .create_source( + CreateSource { + kind: SourceKind::Graphql, + preferred_slug: "state".to_owned(), + display_name: "State".to_owned(), + description: None, + configuration: Map::new(), + }, + AuditContext::system(None), + ) + .await + .expect("source should create"); + sqlx::query("DELETE FROM setup_state") + .execute(app.pool()) + .await + .expect("setup state should delete"); + sqlx::query("DELETE FROM instance_metadata WHERE key = 'boot_sentinel'") + .execute(app.pool()) + .await + .expect("sentinel should delete"); + app.pool().close().await; + drop(app); + let Err(error) = ExecutorApp::open(config).await else { + panic!("catalog state without sentinel must fail closed"); + }; + assert!(matches!(error, DatabaseError::MissingBootSentinel)); +} + +#[tokio::test] +async fn catalog_api_preserves_auth_planes_csrf_and_global_token_equivalence() { + let executor = TestExecutor::new().await; + let source = executor.source("tools").await; + assert_eq!(source.slug, "tools_2"); + executor + .sync(&source.id, vec![staged("run", "Run", ToolMode::Enabled)]) + .await; + let tool = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + executor.setup_admin().await; + let admin = executor.login().await; + let first_token = executor.create_api_token(&admin, "First").await; + let second_token = executor.create_api_token(&admin, "Second").await; + + let admin_list = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(admin_list.status(), StatusCode::OK); + let admin_body = response_json(admin_list).await; + assert_eq!(admin_body["total"], 1); + assert!(admin_body["items"][0].get("inputSchema").is_none()); + assert!(admin_body["items"][0].get("inputTypescript").is_none()); + + let admin_detail = send_empty( + executor.router(), + Method::GET, + &format!("/api/v1/tools/{}", tool.id), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(admin_detail.status(), StatusCode::OK); + assert_eq!( + response_json(admin_detail).await["inputSchema"]["type"], + "object" + ); + + let bearer_control = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_error(bearer_control, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let session_gateway = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/search", + json!({ "query": "run" }), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_error(session_gateway, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let first_search = gateway_search_request(&executor, &first_token, "run").await; + let second_search = gateway_search_request(&executor, &second_token, "run").await; + assert_eq!(first_search, second_search); + assert_eq!(first_search["total"], 1); + let discovered_path = first_search["items"][0]["path"] + .as_str() + .expect("search should return a sandbox path") + .to_owned(); + assert_eq!(discovered_path, "tools_2.run"); + let described = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/describe", + json!({ "path": discovered_path }), + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_eq!(described.status(), StatusCode::OK); + assert!( + response_json(described).await["outputTypescript"] + .as_str() + .is_some_and(|output| output.contains("ToolError")) + ); + + let missing_csrf = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/tools/{}/mode", tool.id), + json!({ "mode": "disabled", "expectedRevision": tool.revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ], + ) + .await; + assert_error(missing_csrf, StatusCode::FORBIDDEN, "invalid_csrf").await; + + let disabled = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/tools/{}/mode", tool.id), + json!({ "mode": "disabled", "expectedRevision": tool.revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(disabled.status(), StatusCode::OK); + let disabled_request_id = disabled + .headers() + .get("x-request-id") + .expect("mutation should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + let disabled_body = response_json(disabled).await; + assert_eq!(disabled_body["effectiveMode"]["mode"], "disabled"); + assert_eq!( + sqlx::query_scalar::<_, Option>( + "SELECT actor_admin_id FROM audit_events \ + WHERE request_id = ? AND action = 'tool.mode_changed'", + ) + .bind(disabled_request_id) + .fetch_one(executor.app.pool()) + .await + .expect("administrator audit actor should read"), + Some(1) + ); + let disabled_revision = disabled_body["revision"] + .as_i64() + .expect("updated revision should be an integer"); + + let missing_mode = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/tools/{}/mode", tool.id), + json!({ "expectedRevision": disabled_revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_error(missing_mode, StatusCode::BAD_REQUEST, "invalid_json").await; + assert_eq!( + executor + .app + .catalog() + .tool(&tool.id) + .await + .expect("tool should remain disabled") + .mode_override, + Some(ToolMode::Disabled) + ); + + assert_eq!( + gateway_search_request(&executor, &first_token, "run").await["total"], + 0 + ); + assert_eq!( + gateway_search_request(&executor, &second_token, "run").await["total"], + 0 + ); + let guarded = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/lookup", + json!({ "path": tool.callable_path }), + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_error(guarded, StatusCode::FORBIDDEN, "tool_disabled").await; + + let logs = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let logs = executor + .app + .catalog() + .list_request_logs(None, 20) + .await + .expect("gateway calls should be readable"); + let disabled_call_recorded = logs.items.iter().any(|log| { + log.path_snapshot.as_deref() == Some(tool.callable_path.as_str()) + && log.error_code.as_deref() == Some("tool_disabled") + }); + let actor_count = logs + .items + .iter() + .filter_map(|log| log.actor_api_token_id.as_deref()) + .collect::>() + .len(); + if disabled_call_recorded && actor_count == 2 { + break logs; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("queued gateway logs should be stored"); + assert!(logs.items.iter().any(|log| { + log.path_snapshot.as_deref() == Some(tool.callable_path.as_str()) + && log.error_code.as_deref() == Some("tool_disabled") + })); + assert_eq!( + logs.items + .iter() + .filter_map(|log| log.actor_api_token_id.as_deref()) + .collect::>() + .len(), + 2 + ); + + let log_list = send_empty( + executor.router(), + Method::GET, + "/api/v1/request-logs?limit=1", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(log_list.status(), StatusCode::OK); + let log_body = response_json(log_list).await; + let log_id = log_body["items"][0]["requestId"] + .as_str() + .expect("log list should return request ID"); + let log_detail = send_empty( + executor.router(), + Method::GET, + &format!("/api/v1/request-logs/{log_id}"), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(log_detail.status(), StatusCode::OK); + + let catalog_revision = executor + .app + .catalog() + .global_revision() + .await + .expect("catalog revision should read"); + let explicit_bulk = send_json( + executor.router(), + Method::PATCH, + "/api/v1/tools/modes", + json!({ + "selection": { + "type": "tool_ids", + "toolIds": [tool.id], + "expectedCatalogRevision": catalog_revision + }, + "mode": "enabled" + }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(explicit_bulk.status(), StatusCode::OK); + assert_eq!(response_json(explicit_bulk).await["updatedCount"], 1); + + let source_revision = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .revision; + let source_bulk = send_json( + executor.router(), + Method::PATCH, + "/api/v1/tools/modes", + json!({ + "selection": { + "type": "source", + "sourceId": source.id, + "expectedSourceRevision": source_revision + }, + "mode": "ask" + }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(source_bulk.status(), StatusCode::OK); + assert_eq!(response_json(source_bulk).await["updatedCount"], 1); + + let ask_lookup = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/lookup", + json!({ "path": discovered_path }), + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {second_token}"), + )], + ) + .await; + assert_eq!(ask_lookup.status(), StatusCode::OK); + assert_eq!(response_json(ask_lookup).await["requiresApproval"], true); + + let sources = send_empty( + executor.router(), + Method::GET, + "/api/v1/sources", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(sources.status(), StatusCode::OK); + assert_eq!( + response_json(sources).await["sources"] + .as_array() + .map(Vec::len), + Some(1) + ); + let source_detail = send_empty( + executor.router(), + Method::GET, + &format!("/api/v1/sources/{}", source.id), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(source_detail.status(), StatusCode::OK); + let source_body = response_json(source_detail).await; + let source_revision = source_body["revision"] + .as_i64() + .expect("source revision should be an integer"); + let source_mode = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/sources/{}/mode", source.id), + json!({ "mode": "disabled", "expectedRevision": source_revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(source_mode.status(), StatusCode::OK); + let deleted = send_empty( + executor.router(), + Method::DELETE, + &format!("/api/v1/sources/{}", source.id), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn gateway_auth_stays_responsive_during_token_timestamp_contention() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let token = executor.create_api_token(&admin, "Contention").await; + let outside_writer = executor + .app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("outside writer should reserve SQLite"); + + for _ in 0..2 { + tokio::time::timeout( + std::time::Duration::from_secs(1), + gateway_whoami_burst(&executor, &token, 24), + ) + .await + .expect("authentication reads should not wait for the timestamp writer"); + } + + outside_writer + .commit() + .await + .expect("outside writer should commit"); + for _ in 0..100 { + let last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE name = 'Contention'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("last-used timestamp should be readable"); + if last_used.is_some() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the coalesced last-used update should settle after contention clears"); +} + +async fn gateway_whoami_burst(executor: &TestExecutor, token: &str, requests: usize) { + let mut requests_in_flight = tokio::task::JoinSet::new(); + for _ in 0..requests { + let router = executor.router(); + let authorization = format!("Bearer {token}"); + requests_in_flight.spawn(async move { + send_empty( + router, + Method::GET, + "/api/v1/gateway/whoami", + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await + .status() + }); + } + while let Some(response) = requests_in_flight.join_next().await { + assert_eq!( + response.expect("gateway authentication task should not panic"), + StatusCode::OK + ); + } +} + +async fn gateway_search_request(executor: &TestExecutor, token: &str, query: &str) -> Value { + let response = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/search", + json!({ "query": query, "limit": 10, "offset": 0 }), + &[(header::AUTHORIZATION.as_str(), &format!("Bearer {token}"))], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + response_json(response).await +} + +async fn send_json( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn send_empty( + router: Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot(request.body(Body::empty()).expect("request should build")) + .await + .expect("router should answer") +} + +fn response_cookie_header(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("set-cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +async fn assert_error(response: Response, status: StatusCode, code: &str) { + assert_eq!(response.status(), status); + let request_id = response + .headers() + .get("x-request-id") + .expect("error should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + let body = response_json(response).await; + assert_eq!(body["error"]["code"], code); + assert_eq!(body["error"]["requestId"], request_id); +} diff --git a/tests/control_plane.rs b/tests/control_plane.rs index 64a233819..4f1f6d60d 100644 --- a/tests/control_plane.rs +++ b/tests/control_plane.rs @@ -6,7 +6,10 @@ use axum::{ extract::ConnectInfo, http::{Method, Request, Response, StatusCode, header}, }; -use executor::{AppConfig, DatabaseError, ExecutorApp}; +use executor::{ + AppConfig, DatabaseError, ExecutorApp, + catalog::{RequestOutcome, RequestSurface}, +}; use http_body_util::BodyExt; use ipnet::IpNet; use serde_json::{Value, json}; @@ -925,12 +928,20 @@ async fn api_tokens_are_revealed_once_revocable_and_gateway_only() { assert_eq!(gateway.status(), StatusCode::OK); let gateway_body = response_json(gateway).await; assert_eq!(gateway_body["tokenId"], token_id); - let last_used = - sqlx::query_scalar::<_, Option>("SELECT last_used_at FROM api_tokens WHERE id = ?") - .bind(&token_id) - .fetch_one(executor.app.pool()) - .await - .expect("last-used timestamp should be readable"); + let mut last_used = None; + for _ in 0..100 { + last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE id = ?", + ) + .bind(&token_id) + .fetch_one(executor.app.pool()) + .await + .expect("last-used timestamp should be readable"); + if last_used.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } assert!(last_used.is_some()); let bearer_on_control_plane = send_empty( @@ -970,6 +981,211 @@ async fn api_tokens_are_revealed_once_revocable_and_gateway_only() { assert_error(revoked_gateway, StatusCode::UNAUTHORIZED, "unauthorized").await; } +#[tokio::test] +async fn gateway_request_logging_does_not_wait_for_the_sqlite_writer() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let created = create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(created.status(), StatusCode::CREATED); + let created_body = response_json(created).await; + let token = created_body["token"] + .as_str() + .expect("create should reveal the token") + .to_owned(); + let token_id = created_body["id"] + .as_str() + .expect("create should return the token ID") + .to_owned(); + + let mut writer = executor + .app + .pool() + .acquire() + .await + .expect("test writer connection should be acquired"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .expect("test writer should hold the SQLite write lock"); + + let authorization = format!("Bearer {token}"); + let response = tokio::time::timeout( + std::time::Duration::from_millis(200), + send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/search", + json!({ "query": "nothing" }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ), + ) + .await + .expect("gateway reads must return without waiting for request-log persistence"); + assert_eq!(response.status(), StatusCode::OK); + let request_id = response + .headers() + .get("x-request-id") + .expect("gateway response should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + + sqlx::query("COMMIT") + .execute(&mut *writer) + .await + .expect("test writer should release the SQLite write lock"); + drop(writer); + + let stored = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if let Ok(stored) = executor.app.catalog().request_log(&request_id).await { + break stored; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("queued request log should persist after writer contention clears"); + assert_eq!( + stored.actor_api_token_id.as_deref(), + Some(token_id.as_str()) + ); + assert_eq!(stored.surface, RequestSurface::Gateway); + assert_eq!(stored.path_snapshot.as_deref(), Some("tools.search")); + assert_eq!(stored.outcome, RequestOutcome::Succeeded); +} + +#[tokio::test] +async fn token_last_used_retries_after_write_slot_contention() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + + let first_created = + create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(first_created.status(), StatusCode::CREATED); + let first_body = response_json(first_created).await; + let first_token = first_body["token"] + .as_str() + .expect("first token should be revealed") + .to_owned(); + let first_token_id = first_body["id"] + .as_str() + .expect("first token ID should be returned") + .to_owned(); + + let second_created = + create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(second_created.status(), StatusCode::CREATED); + let second_body = response_json(second_created).await; + let second_token = second_body["token"] + .as_str() + .expect("second token should be revealed") + .to_owned(); + let second_token_id = second_body["id"] + .as_str() + .expect("second token ID should be returned") + .to_owned(); + + let mut writer = executor + .app + .pool() + .acquire() + .await + .expect("test writer connection should be acquired"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .expect("test writer should hold the SQLite write lock"); + + let first_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_eq!(first_gateway.status(), StatusCode::OK); + + let mut telemetry_writer_started = false; + for _ in 0..100 { + let checked_out_connections = + executor.app.pool().size() as usize - executor.app.pool().num_idle(); + if checked_out_connections >= 2 { + telemetry_writer_started = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(telemetry_writer_started); + + let contended_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {second_token}"), + )], + ) + .await; + assert_eq!(contended_gateway.status(), StatusCode::OK); + + sqlx::query("COMMIT") + .execute(&mut *writer) + .await + .expect("test writer should release the SQLite write lock"); + drop(writer); + + let mut first_last_used = None; + for _ in 0..100 { + first_last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE id = ?", + ) + .bind(&first_token_id) + .fetch_one(executor.app.pool()) + .await + .expect("first token last-used timestamp should be readable"); + if first_last_used.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(first_last_used.is_some()); + + let retry_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {second_token}"), + )], + ) + .await; + assert_eq!(retry_gateway.status(), StatusCode::OK); + + let mut second_last_used = None; + for _ in 0..100 { + second_last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE id = ?", + ) + .bind(&second_token_id) + .fetch_one(executor.app.pool()) + .await + .expect("second token last-used timestamp should be readable"); + if second_last_used.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(second_last_used.is_some()); +} + #[tokio::test] async fn token_names_are_counted_by_characters_and_stored_trimmed() { let executor = TestExecutor::new().await; @@ -1001,6 +1217,14 @@ async fn health_bootstrap_and_errors_have_stable_shapes() { assert!(health.headers().contains_key("x-request-id")); assert_eq!(response_json(health).await, json!({ "status": "ok" })); + let health_method_mismatch = send_empty(executor.router(), Method::POST, "/healthz", &[]).await; + assert_error( + health_method_mismatch, + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + ) + .await; + let before = send_empty(executor.router(), Method::GET, "/api/v1/bootstrap", &[]).await; assert_eq!( response_json(before).await, @@ -1025,6 +1249,41 @@ async fn health_bootstrap_and_errors_have_stable_shapes() { assert_error(missing, StatusCode::NOT_FOUND, "not_found").await; } +#[tokio::test] +async fn catalog_route_rejections_have_stable_error_envelopes() { + let executor = TestExecutor::new().await; + + let method_mismatch = send_empty(executor.router(), Method::POST, "/api/v1/tools", &[]).await; + assert_error( + method_mismatch, + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + ) + .await; + + executor.setup_admin().await; + let admin = executor.login().await; + let headers = [(header::COOKIE.as_str(), admin.cookie.as_str())]; + + let invalid_query = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools?limit=not-a-number", + &headers, + ) + .await; + assert_error(invalid_query, StatusCode::BAD_REQUEST, "invalid_query").await; + + let invalid_path = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools/%FF", + &headers, + ) + .await; + assert_error(invalid_path, StatusCode::BAD_REQUEST, "invalid_path").await; +} + async fn create_token_request( executor: &TestExecutor, admin: &AdminSession, From b996971dfecbe56543cbdb5bb00f0f8840427176 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Tue, 23 Jun 2026 22:23:45 -0700 Subject: [PATCH 03/24] feat: add OpenAPI sources and catalog UI --- Cargo.lock | 593 ++++- Cargo.toml | 21 +- docs/architecture.md | 85 +- migrations/20260623020000_tool_bindings.sql | 17 + src/api.rs | 49 +- src/api/openapi.rs | 892 +++++++ src/api/protocols.rs | 483 ++++ src/catalog/mod.rs | 125 + src/catalog/schema.rs | 103 + src/catalog/store.rs | 1377 ++++++++-- src/lib.rs | 2 + src/openapi.rs | 2637 +++++++++++++++++++ src/outbound.rs | 633 +++++ tests/catalog.rs | 228 +- tests/invocation_preparation.rs | 177 ++ tests/openapi_api.rs | 788 ++++++ tests/openapi_atomic_create.rs | 198 ++ tests/openapi_gateway_contract.rs | 832 ++++++ tests/openapi_lifecycle.rs | 636 +++++ tests/openapi_parser.rs | 1502 +++++++++++ tests/openapi_storage.rs | 337 +++ tests/outbound.rs | 266 ++ web/src/lib/DashboardShell.svelte | 18 +- web/src/lib/api.test.ts | 345 ++- web/src/lib/api.ts | 453 ++++ web/src/lib/catalog-state.test.ts | 201 ++ web/src/lib/catalog-state.ts | 101 + web/src/lib/catalog-url.test.ts | 61 + web/src/lib/catalog-url.ts | 82 + web/src/lib/catalog-ux.test.ts | 37 + web/src/lib/catalog-ux.ts | 33 + web/src/lib/openapi-credentials.test.ts | 50 + web/src/lib/openapi-credentials.ts | 49 + web/src/routes/logs/+page.svelte | 285 +- web/src/routes/sources/+page.svelte | 1085 +++++++- web/src/routes/tools/+page.svelte | 736 +++++- web/src/styles.css | 613 ++++- 37 files changed, 15815 insertions(+), 315 deletions(-) create mode 100644 migrations/20260623020000_tool_bindings.sql create mode 100644 src/api/openapi.rs create mode 100644 src/api/protocols.rs create mode 100644 src/catalog/schema.rs create mode 100644 src/openapi.rs create mode 100644 src/outbound.rs create mode 100644 tests/invocation_preparation.rs create mode 100644 tests/openapi_api.rs create mode 100644 tests/openapi_atomic_create.rs create mode 100644 tests/openapi_gateway_contract.rs create mode 100644 tests/openapi_lifecycle.rs create mode 100644 tests/openapi_parser.rs create mode 100644 tests/openapi_storage.rs create mode 100644 tests/outbound.rs create mode 100644 web/src/lib/catalog-state.test.ts create mode 100644 web/src/lib/catalog-state.ts create mode 100644 web/src/lib/catalog-url.test.ts create mode 100644 web/src/lib/catalog-url.ts create mode 100644 web/src/lib/catalog-ux.test.ts create mode 100644 web/src/lib/catalog-ux.ts create mode 100644 web/src/lib/openapi-credentials.test.ts create mode 100644 web/src/lib/openapi-credentials.ts diff --git a/Cargo.lock b/Cargo.lock index a5892cd7b..503adddeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -180,6 +194,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.0" @@ -207,12 +236,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -241,6 +282,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -383,10 +430,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -457,6 +510,15 @@ dependencies = [ "serde", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -510,9 +572,13 @@ dependencies = [ "hmac", "http-body-util", "ipnet", - "rand", + "jsonschema", + "percent-encoding", + "rand 0.8.6", + "reqwest", "serde", "serde_json", + "serde_yaml", "sha2", "sqlx", "subtle", @@ -526,6 +592,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -538,6 +615,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.11.1" @@ -555,6 +643,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -564,6 +658,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -652,8 +756,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -664,7 +784,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -675,7 +795,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -795,6 +926,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", ] [[package]] @@ -803,13 +951,21 @@ 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", "tokio", "tower-service", + "tracing", ] [[package]] @@ -963,6 +1119,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.46.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8374249b1bdce1c1773a09fa294347e19e4bfeb86d845c2d8ebaf8a8dbf11233" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1034,6 +1217,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1065,6 +1254,12 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1091,6 +1286,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1102,11 +1321,26 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1127,6 +1361,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1161,6 +1406,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -1197,7 +1448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1293,6 +1544,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.46" @@ -1302,6 +1608,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1315,8 +1627,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1326,7 +1648,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1338,6 +1670,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1367,6 +1708,55 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.46.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a910f9d63351f9c9d7dc3053d02b214ea3a005917140c70087d7b59cbc5bb1" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1384,6 +1774,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "ring" version = "0.17.14" @@ -1411,13 +1839,19 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -1451,6 +1885,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1549,6 +1984,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1603,7 +2051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1764,7 +2212,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "sha1", "sha2", @@ -1801,7 +2249,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "serde", "serde_json", "sha2", @@ -1882,6 +2330,9 @@ 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" @@ -1988,6 +2439,16 @@ dependencies = [ "syn", ] +[[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-stream" version = "0.1.18" @@ -2015,6 +2476,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2089,6 +2568,12 @@ dependencies = [ "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.20.1" @@ -2101,6 +2586,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2132,6 +2623,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -2174,6 +2671,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2192,12 +2699,36 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -2217,6 +2748,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.125" @@ -2249,6 +2790,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2431,6 +2992,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index ece8cb15a..82de016b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,14 +14,31 @@ directories = "6" hkdf = "0.12" hmac = "0.12" ipnet = "2.12.0" +jsonschema = { version = "0.46.6", default-features = false } +percent-encoding = "2" rand = "0.8.5" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" sha2 = "0.10" -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "migrate", "macros"] } +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio-rustls", + "sqlite", + "migrate", + "macros", +] } subtle = "2" thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "fs", "sync"] } +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "net", + "signal", + "time", + "fs", + "sync", +] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" diff --git a/docs/architecture.md b/docs/architecture.md index 4b20425d4..70d6695d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -132,12 +132,31 @@ caller with an old path from bypassing the catalog. Ask tools remain discoverable and carry approval-required metadata. Protocol discovery and schema normalization happen before a catalog write. -The staged snapshot records the source and credential revisions it used. The -commit rechecks both revisions, serializes catalog writers, replaces source +Initial creation uses a create-only staged snapshot with no meaningless CAS +fields. Refresh snapshots record the source and credential revisions they used. +The commit rechecks both revisions, serializes catalog writers, replaces source artifacts, upserts tools by `(source_id, stable_key)`, tombstones missing tools, -and advances the source and global catalog revisions in one transaction. A -failed stage or stale revision leaves the last known good catalog untouched. -Network work never occurs inside this transaction. +and advances the source and global catalog revisions in one transaction. Create +and refresh share the same transaction-scoped artifact, tool, binding, and +search-index application helpers. A failed stage or stale revision leaves the +last known good catalog untouched. Network work never occurs inside this +transaction. + +Tool bindings use a closed Rust enum and a generic persisted wire vocabulary: +protocol, positive binding version, and private JSON definition. SQLite reserves +the supported source protocol names, while Rust rejects protocol/version pairs +that have no implemented typed variant. Generic source creation and gateway +invocation routes own authentication, limits, logging, and typed dispatch; +OpenAPI owns only its preview, compiler, credential adapter, and invocation +adapter. + +Invocation admission acquires a catalog read lease and reads tool presence, +effective mode, source configuration, credential revision, and typed binding in +one coherent SQLite snapshot. Policy-affecting writers take the matching write +gate, so the admitted policy cannot change before or during outbound execution. +Ask releases the lease without network work and retains a revision token. A +future approval continuation must reacquire a fresh lease and revalidate every +source, tool, binding, catalog, and credential revision before execution. Administrator catalog reads require a session cookie. Catalog mutations also require the matching Origin, CSRF cookie, and CSRF header. Gateway discovery, @@ -153,6 +172,10 @@ are: - `POST /api/v1/gateway/tools/search` - `POST /api/v1/gateway/tools/describe` - `POST /api/v1/gateway/tools/lookup` +- `POST /api/v1/sources/openapi/preview` +- `POST /api/v1/sources` and `POST /api/v1/sources/{id}/refresh` +- `GET`, `PUT`, and `DELETE /api/v1/sources/{id}/credentials` +- `POST /api/v1/gateway/tools/invoke` Request logs store metadata only: request ID, nullable actor token ID, surface, nullable source and tool IDs, an immutable callable-path snapshot, outcome, @@ -161,6 +184,58 @@ store request headers, credentials, arguments, or results. Source and tool deletion clears their foreign keys while retaining the history and path snapshot. +## OpenAPI import and invocation + +OpenAPI 3.0 and 3.1 documents can be previewed from pasted JSON or YAML and +from an HTTP URL. Swagger 2 documents and external references fail closed. +Local references have cycle and depth limits. Tool identities are derived from +the HTTP method and exact path, so operation ID and display-name changes do not +change tool IDs, callable names, or administrator mode overrides on refresh. + +The persisted tool binding contains only typed protocol metadata. Static API keys, +bearer tokens, basic credentials, manually supplied OAuth access tokens, and +query-bearing source URLs live only in the source-bound encrypted credential +envelope. The source configuration and admin response expose a query-stripped +display URL. Refresh +performs network and compilation work before the catalog transaction, then +commits artifacts, tools, tombstones, bindings, and health together under +source and credential revision checks. A failed refresh leaves the last good +callable catalog in place. + +Outbound requests resolve and validate every DNS answer, pin the validated +addresses into a proxy-free client, and reject mixed safe and unsafe answers. +Private and loopback targets require a per-source administrator opt-in. +Link-local, metadata, reserved, documentation, and multicast targets remain +blocked even with that opt-in. Spec redirects are manual, bounded, revalidated +at every hop, and cannot downgrade HTTPS. Tool invocation never follows +redirects. Request and response headers, bodies, time, DNS answers, redirects, +and document sizes have hard limits. + +The first invocation surface deliberately supports this exact serialization +subset: + +- Path and header parameters use `simple` style. +- Query parameters use `form`, `spaceDelimited`, `pipeDelimited`, or + `deepObject`; `allowReserved` is not accepted. +- Cookie parameters use `form`, including scalar, array, and object explode + behavior. +- Request bodies support JSON and `+json`, string-valued `text/plain`, and + closed scalar-object `application/x-www-form-urlencoded` schemas. +- Parameter `content`, multipart bodies, arbitrary media types, and other + styles fail during import instead of producing tools that cannot execute. + +Enabled tools execute immediately, disabled or removed tools cannot reach the +transport, and Ask tools return the stable `approval_required` seam without +executing. Request logs remain metadata-only and never contain arguments, +credentials, upstream bodies, or results. + +OAuth2 and OpenID Connect operations expose their declared flow metadata for a +later OAuth setup UI. This slice accepts a manually supplied access token in +the encrypted credential envelope, labeled `manual_oauth_access_token` in +metadata. It does not yet claim a managed OAuth flow. State and PKCE handling, +browser callbacks, authorization-code exchange, refresh-token rotation, and +provider error recovery remain part of the dedicated OAuth slice. + Audit history is bounded to the most recently inserted 10,000 events for a single-user instance. Each event's serialized metadata is capped at 64 KiB. Insertion and oldest-insertion compaction happen in the same catalog diff --git a/migrations/20260623020000_tool_bindings.sql b/migrations/20260623020000_tool_bindings.sql new file mode 100644 index 000000000..1bfe67f0f --- /dev/null +++ b/migrations/20260623020000_tool_bindings.sql @@ -0,0 +1,17 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE tool_bindings ( + tool_id TEXT PRIMARY KEY NOT NULL REFERENCES tools(id) ON DELETE CASCADE, + protocol TEXT NOT NULL + CHECK (protocol IN ('openapi', 'graphql', 'mcp_http', 'mcp_stdio')), + binding_version INTEGER NOT NULL CHECK (binding_version > 0), + definition_json TEXT NOT NULL + CHECK (json_valid(definition_json)) + CHECK (json_type(definition_json) = 'object'), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX tool_bindings_protocol_idx + ON tool_bindings(protocol, binding_version, tool_id); diff --git a/src/api.rs b/src/api.rs index 32a03fe3e..30218292d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,8 +7,11 @@ use std::{ use axum::{ Json, Router, - extract::{ConnectInfo, DefaultBodyLimit, Extension, Path, State, rejection::JsonRejection}, - http::{HeaderMap, HeaderValue, StatusCode, header}, + extract::{ + ConnectInfo, DefaultBodyLimit, Extension, FromRequestParts, Path, State, + rejection::JsonRejection, + }, + http::{HeaderMap, HeaderValue, StatusCode, header, request::Parts}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get, post}, @@ -28,6 +31,8 @@ use crate::{ }; mod catalog; +mod openapi; +mod protocols; mod request_logs; use request_logs::GatewayRequestLogSink; @@ -403,6 +408,10 @@ struct GatewayIdentity { token_name: String, } +struct AdminMutation(i64); + +struct GatewayAuthentication(GatewayIdentity); + struct AdminSession { id: i64, username: String, @@ -410,6 +419,40 @@ struct AdminSession { csrf_digest: Vec, } +impl FromRequestParts for AdminMutation { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let request_id = parts + .extensions + .get::() + .expect("request ID middleware runs before authentication") + .clone(); + let admin = require_admin_mutation(&request_id, state, &parts.headers).await?; + Ok(Self(admin.id)) + } +} + +impl FromRequestParts for GatewayAuthentication { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let request_id = parts + .extensions + .get::() + .expect("request ID middleware runs before authentication") + .clone(); + let identity = require_gateway_token(&request_id, state, &parts.headers).await?; + Ok(Self(identity)) + } +} + pub(crate) fn router( database: Database, catalog: CatalogStore, @@ -442,6 +485,8 @@ pub(crate) fn router( .route("/api/v1/tokens/{id}", delete(revoke_token)) .route("/api/v1/gateway/whoami", get(gateway_whoami)) .merge(catalog::router()) + .merge(protocols::router()) + .merge(openapi::router()) .fallback(not_found) .method_not_allowed_fallback(method_not_allowed) .with_state(state) diff --git a/src/api/openapi.rs b/src/api/openapi.rs new file mode 100644 index 000000000..f8a94594b --- /dev/null +++ b/src/api/openapi.rs @@ -0,0 +1,892 @@ +use std::collections::BTreeMap; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Extension, Path, Query, State, rejection::JsonRejection}, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, + routing::{get, post}, +}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use url::Url; + +use super::{ + AdminMutation, ApiError, AppState, RequestId, parse_json, protocols::InvocationAdapterError, + require_admin, require_admin_mutation, +}; +use crate::{ + catalog::{ + ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, + InitialCatalogSnapshot, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, + StoredCredential, ToolBinding, + }, + openapi::{ + CompiledOpenApi, OpenApiBinding, OpenApiCredentialSet, OpenApiError, + OpenApiInvocationError, OpenApiParameterLocation, OpenApiSecurityScheme, + build_protocol_request_with_base, compile_document, + }, + outbound::{HardenedHttpClient, OutboundError, OutboundPolicy, OutboundRequest, parse_url}, +}; + +const MAX_SPEC_BYTES: usize = 16 * 1024 * 1024; + +pub(super) fn router() -> Router { + Router::new() + .route("/api/v1/sources/openapi/preview", post(preview)) + .layer(DefaultBodyLimit::max(MAX_SPEC_BYTES + 64 * 1024)) + .merge( + Router::new() + .route("/api/v1/sources/{id}/refresh", post(refresh_source)) + .route( + "/api/v1/sources/{id}/credentials", + get(get_credentials) + .put(put_credentials) + .delete(delete_credentials), + ), + ) +} + +#[derive(Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum OpenApiSpecInput { + Inline { content: String }, + Url { url: String }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreviewRequest { + spec: OpenApiSpecInput, + #[serde(default)] + allow_private_network: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewResponse { + title: String, + description: Option, + tool_count: usize, + tools: Vec, + security_schemes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewTool { + preferred_name: String, + display_name: String, + description: Option, + intrinsic_mode: crate::catalog::ToolMode, + security: Vec>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewSecurityScheme { + name: String, + credential_type: &'static str, + placement: Option<&'static str>, + supported: bool, + oauth_flows: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CreateOpenApiSourceRequest { + display_name: String, + preferred_slug: Option, + description: Option, + spec: OpenApiSpecInput, + #[serde(default)] + allow_private_network: bool, + #[serde(default)] + credential: OpenApiCredentialSet, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +enum StoredLocator { + Inline, + Url { + url: String, + document_base_url: String, + }, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct StoredOpenApiCredential { + locator: StoredLocator, + credentials: OpenApiCredentialSet, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PutCredentialsRequest { + expected_revision: Option, + credential: OpenApiCredentialSet, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteCredentialsQuery { + expected_revision: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CredentialMetadata { + revision: i64, + configured_schemes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfiguredCredential { + name: String, + credential_type: &'static str, +} + +async fn get_credentials( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + Path(source_id): Path, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + require_openapi_source(&state, &request_id, &source_id).await?; + credential_metadata(&state, &request_id, &source_id).await +} + +async fn preview( + Extension(request_id): Extension, + AdminMutation(_admin_id): AdminMutation, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let Json(payload) = parse_json(&request_id, payload)?; + let fetched = fetch_and_compile(&payload.spec, payload.allow_private_network) + .await + .map_err(|error| import_error(&request_id, error))?; + Ok(Json(preview_response(fetched.compiled))) +} + +pub(super) async fn create_source( + state: &AppState, + request_id: &RequestId, + admin_id: i64, + payload: CreateOpenApiSourceRequest, +) -> Result { + validate_credentials(request_id, &payload.credential)?; + let fetched = fetch_and_compile(&payload.spec, payload.allow_private_network) + .await + .map_err(|error| import_error(request_id, error))?; + let configuration = source_configuration(&payload.spec, payload.allow_private_network) + .map_err(|error| import_error(request_id, error))?; + let preferred_slug = payload + .preferred_slug + .unwrap_or_else(|| payload.display_name.clone()); + let audit = AuditContext::admin(&request_id.0, admin_id); + let credential = StoredOpenApiCredential { + locator: fetched.locator, + credentials: payload.credential, + }; + let snapshot = initial_catalog_snapshot(&fetched.compiled); + let bindings = staged_bindings(&fetched.compiled); + let (source, _) = state + .catalog + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug, + display_name: payload.display_name, + description: payload.description, + configuration, + }, + &credential_payload(&credential) + .map_err(|error| ApiError::internal_logged(request_id, error))?, + snapshot, + bindings, + audit, + ) + .await + .map_err(|error| catalog_error(request_id, error))?; + Ok(source) +} + +async fn refresh_source( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + Path(source_id): Path, +) -> Result, ApiError> { + let admin = require_admin_mutation(&request_id, &state, &headers).await?; + let source = require_openapi_source(&state, &request_id, &source_id).await?; + let stored = stored_credential(&state, &request_id, &source_id).await?; + let allow_private_network = source + .configuration + .get("allowPrivateNetwork") + .and_then(Value::as_bool) + .unwrap_or(false); + let fetched = match &stored.1.locator { + StoredLocator::Inline => { + let document = sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ? \ + AND artifact_kind = 'openapi_document' AND stable_key = 'document'", + ) + .bind(&source_id) + .fetch_optional(state.catalog.pool()) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))? + .ok_or_else(|| { + ApiError::new( + &request_id, + StatusCode::CONFLICT, + "source_artifact_missing", + "The source has no OpenAPI document to refresh.", + ) + })?; + FetchedSpec { + compiled: compile_bytes(document.into_bytes()) + .await + .map_err(|error| import_error(&request_id, error))?, + locator: StoredLocator::Inline, + } + } + StoredLocator::Url { url, .. } => fetch_url(url, allow_private_network) + .await + .map_err(|error| import_error(&request_id, error))?, + }; + let result = import_compiled( + &state, + &source_id, + source.revision, + stored.0, + fetched.compiled, + AuditContext::admin(&request_id.0, admin.id), + ) + .await?; + Ok(Json(result)) +} + +async fn put_credentials( + Extension(request_id): Extension, + State(state): State, + Path(source_id): Path, + AdminMutation(admin_id): AdminMutation, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let Json(payload) = parse_json(&request_id, payload)?; + require_openapi_source(&state, &request_id, &source_id).await?; + validate_credentials(&request_id, &payload.credential)?; + let (revision, mut stored) = stored_credential(&state, &request_id, &source_id).await?; + if payload.expected_revision != Some(revision) { + return Err(revision_conflict(&request_id)); + } + stored.credentials = payload.credential; + state + .catalog + .put_credential( + &source_id, + &credential_payload(&stored) + .map_err(|error| ApiError::internal_logged(&request_id, error))?, + Some(revision), + AuditContext::admin(&request_id.0, admin_id), + ) + .await + .map_err(|error| catalog_error(&request_id, error))?; + credential_metadata(&state, &request_id, &source_id).await +} + +async fn delete_credentials( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + Path(source_id): Path, + Query(query): Query, +) -> Result, ApiError> { + let admin = require_admin_mutation(&request_id, &state, &headers).await?; + require_openapi_source(&state, &request_id, &source_id).await?; + let (revision, mut stored) = stored_credential(&state, &request_id, &source_id).await?; + if query.expected_revision != revision { + return Err(revision_conflict(&request_id)); + } + stored.credentials = OpenApiCredentialSet::default(); + state + .catalog + .put_credential( + &source_id, + &credential_payload(&stored) + .map_err(|error| ApiError::internal_logged(&request_id, error))?, + Some(revision), + AuditContext::admin(&request_id.0, admin.id), + ) + .await + .map_err(|error| catalog_error(&request_id, error))?; + credential_metadata(&state, &request_id, &source_id).await +} + +struct FetchedSpec { + compiled: CompiledOpenApi, + locator: StoredLocator, +} + +#[derive(Debug)] +enum ImportError { + OpenApi(OpenApiError), + Outbound(OutboundError), + TooLarge, + InlineServerRequired, +} + +async fn fetch_and_compile( + spec: &OpenApiSpecInput, + allow_private_network: bool, +) -> Result { + match spec { + OpenApiSpecInput::Inline { content } => { + if content.len() > MAX_SPEC_BYTES { + return Err(ImportError::TooLarge); + } + let compiled = compile_bytes(content.as_bytes().to_vec()).await?; + if compiled + .tools + .iter() + .any(|tool| Url::parse(&tool.binding.server_url).is_err()) + { + return Err(ImportError::InlineServerRequired); + } + Ok(FetchedSpec { + compiled, + locator: StoredLocator::Inline, + }) + } + OpenApiSpecInput::Url { url } => fetch_url(url, allow_private_network).await, + } +} + +async fn fetch_url(url: &str, allow_private_network: bool) -> Result { + let policy = OutboundPolicy { + allow_private_networks: allow_private_network, + max_response_bytes: MAX_SPEC_BYTES, + ..OutboundPolicy::default() + }; + let url = parse_url(url, &policy).map_err(ImportError::Outbound)?; + let response = HardenedHttpClient::new(policy) + .fetch_spec(url.clone(), HeaderMap::new()) + .await + .map_err(ImportError::Outbound)?; + let mut compiled = compile_bytes(response.body).await?; + for tool in &mut compiled.tools { + tool.binding.server_url = response + .final_url + .join(&tool.binding.server_url) + .map_err(|_| { + ImportError::OpenApi(OpenApiError::InvalidDocument("a server URL is invalid")) + })? + .to_string(); + } + Ok(FetchedSpec { + compiled, + locator: StoredLocator::Url { + url: url.to_string(), + document_base_url: response.final_url.to_string(), + }, + }) +} + +async fn compile_bytes(bytes: Vec) -> Result { + tokio::task::spawn_blocking(move || compile_document(&bytes)) + .await + .map_err(|_| { + ImportError::OpenApi(OpenApiError::InvalidDocument("OpenAPI compilation failed")) + })? + .map_err(ImportError::OpenApi) +} + +async fn import_compiled( + state: &AppState, + source_id: &str, + source_revision: i64, + credential_revision: i64, + compiled: CompiledOpenApi, + audit: AuditContext<'_>, +) -> Result { + let request_id = RequestId(audit.request_id().unwrap_or("openapi-import").to_owned()); + let snapshot = catalog_snapshot(&compiled, source_revision, credential_revision); + let bindings = staged_bindings(&compiled); + let result = state + .catalog + .sync_catalog_with_bindings(source_id, snapshot, bindings, audit) + .await + .map_err(|error| catalog_error(&request_id, error))?; + Ok(result) +} + +fn initial_catalog_snapshot(compiled: &CompiledOpenApi) -> InitialCatalogSnapshot { + InitialCatalogSnapshot { + artifacts: staged_artifacts(compiled), + tools: staged_tools(compiled), + } +} + +fn catalog_snapshot( + compiled: &CompiledOpenApi, + source_revision: i64, + credential_revision: i64, +) -> CatalogSnapshot { + CatalogSnapshot { + expected_source_revision: source_revision, + expected_credential_revision: Some(credential_revision), + artifacts: staged_artifacts(compiled), + tools: staged_tools(compiled), + } +} + +fn staged_tools(compiled: &CompiledOpenApi) -> Vec { + compiled + .tools + .iter() + .map(|tool| StagedTool { + stable_key: tool.stable_key.clone(), + preferred_name: tool.preferred_name.clone(), + display_name: tool.display_name.clone(), + description: tool.description.clone(), + input_schema: tool.input_schema.clone(), + output_schema: tool.output_schema.clone(), + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: tool.intrinsic_mode, + }) + .collect() +} + +fn staged_bindings(compiled: &CompiledOpenApi) -> Vec { + compiled + .tools + .iter() + .map(|tool| StagedToolBinding { + stable_key: tool.stable_key.clone(), + binding: ToolBinding::OpenapiV1(tool.binding.clone()), + }) + .collect() +} + +fn staged_artifacts(compiled: &CompiledOpenApi) -> Vec { + vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: compiled.document.clone(), + }] +} + +fn preview_response(compiled: CompiledOpenApi) -> PreviewResponse { + let tool_count = compiled.tools.len(); + let mut security_schemes = BTreeMap::new(); + for tool in &compiled.tools { + for alternative in &tool.binding.security { + for requirement in &alternative.requirements { + security_schemes + .entry(requirement.scheme_name.clone()) + .or_insert_with(|| preview_security_scheme(requirement)); + } + } + } + PreviewResponse { + title: compiled.title, + description: compiled.description, + tool_count, + tools: compiled + .tools + .into_iter() + .map(|tool| PreviewTool { + preferred_name: tool.preferred_name, + display_name: tool.display_name, + description: tool.description, + intrinsic_mode: tool.intrinsic_mode, + security: tool + .binding + .security + .into_iter() + .map(|alternative| { + alternative + .requirements + .into_iter() + .map(|requirement| requirement.scheme_name) + .collect() + }) + .collect(), + }) + .collect(), + security_schemes: security_schemes.into_values().collect(), + } +} + +fn preview_security_scheme( + requirement: &crate::openapi::OpenApiSecurityRequirement, +) -> PreviewSecurityScheme { + let (credential_type, placement, supported) = match &requirement.scheme { + OpenApiSecurityScheme::ApiKey { location, .. } => ( + "api_key", + Some(match location { + OpenApiParameterLocation::Header => "header", + OpenApiParameterLocation::Query => "query", + OpenApiParameterLocation::Cookie => "cookie", + OpenApiParameterLocation::Path => "path", + }), + *location != OpenApiParameterLocation::Path, + ), + OpenApiSecurityScheme::Http { scheme, .. } if scheme == "bearer" => { + ("bearer", Some("header"), true) + } + OpenApiSecurityScheme::Http { scheme, .. } if scheme == "basic" => { + ("basic", Some("header"), true) + } + OpenApiSecurityScheme::Http { .. } => ("http", Some("header"), false), + OpenApiSecurityScheme::OAuth2 => ("manual_oauth_access_token", Some("header"), true), + OpenApiSecurityScheme::OpenIdConnect { .. } => { + ("manual_oauth_access_token", Some("header"), true) + } + OpenApiSecurityScheme::MutualTls => ("mutual_tls", None, false), + }; + PreviewSecurityScheme { + name: requirement.scheme_name.clone(), + credential_type, + placement, + supported, + oauth_flows: requirement.oauth_flows.clone(), + } +} + +fn source_configuration( + spec: &OpenApiSpecInput, + allow_private_network: bool, +) -> Result, ImportError> { + let spec = match spec { + OpenApiSpecInput::Inline { .. } => json!({ "type": "inline" }), + OpenApiSpecInput::Url { url } => { + let mut display = + Url::parse(url).map_err(|_| ImportError::Outbound(OutboundError::InvalidUrl))?; + display.set_query(None); + json!({ "type": "url", "displayUrl": display.to_string() }) + } + }; + Ok(Map::from_iter([ + ("spec".to_owned(), spec), + ( + "allowPrivateNetwork".to_owned(), + Value::Bool(allow_private_network), + ), + ])) +} + +fn credential_payload( + credential: &StoredOpenApiCredential, +) -> Result { + Ok(CredentialPayload { + schema_version: 1, + payload: serde_json::to_value(credential)?, + }) +} + +async fn require_openapi_source( + state: &AppState, + request_id: &RequestId, + source_id: &str, +) -> Result { + let source = state + .catalog + .source(source_id) + .await + .map_err(|error| catalog_error(request_id, error))?; + if source.kind != SourceKind::Openapi { + return Err(ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "unsupported_source_kind", + "Only OpenAPI sources can use this endpoint.", + )); + } + Ok(source) +} + +async fn stored_credential( + state: &AppState, + request_id: &RequestId, + source_id: &str, +) -> Result<(i64, StoredOpenApiCredential), ApiError> { + let stored = state + .catalog + .credential(source_id) + .await + .map_err(|error| catalog_error(request_id, error))? + .ok_or_else(|| { + ApiError::new( + request_id, + StatusCode::CONFLICT, + "source_credentials_missing", + "The source credential state is missing.", + ) + })?; + if stored.credential.schema_version != 1 { + return Err(ApiError::new( + request_id, + StatusCode::CONFLICT, + "unsupported_credential_schema", + "The stored OpenAPI credential schema is not supported.", + )); + } + let credential = serde_json::from_value(stored.credential.payload) + .map_err(|error| ApiError::internal_logged(request_id, error))?; + Ok((stored.revision, credential)) +} + +async fn credential_metadata( + state: &AppState, + request_id: &RequestId, + source_id: &str, +) -> Result, ApiError> { + let (revision, stored) = stored_credential(state, request_id, source_id).await?; + Ok(Json(CredentialMetadata { + revision, + configured_schemes: stored + .credentials + .schemes + .into_iter() + .map(|(name, credential)| ConfiguredCredential { + name, + credential_type: credential.credential_type(), + }) + .collect(), + })) +} + +fn validate_credentials( + request_id: &RequestId, + credentials: &OpenApiCredentialSet, +) -> Result<(), ApiError> { + credentials.validate().map_err(|_| { + ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_credentials", + "The static credential configuration is invalid.", + ) + }) +} + +pub(super) struct OpenApiInvocationPlan { + pub request: OutboundRequest, + pub policy: OutboundPolicy, +} + +pub(super) fn invocation_plan( + binding: &OpenApiBinding, + source_configuration: &Map, + stored: Option<&StoredCredential>, + arguments: &Value, +) -> Result { + let stored = stored.ok_or(InvocationAdapterError { + code: "source_credentials_missing", + message: "The source credential state is missing.", + })?; + if stored.credential.schema_version != 1 { + return Err(InvocationAdapterError { + code: "unsupported_credential_schema", + message: "The stored OpenAPI credential schema is not supported.", + }); + } + let credential: StoredOpenApiCredential = + serde_json::from_value(stored.credential.payload.clone()).map_err(|_| { + InvocationAdapterError { + code: "invalid_source_credentials", + message: "The source credential state is invalid.", + } + })?; + let document_base_url = match &credential.locator { + StoredLocator::Inline => None, + StoredLocator::Url { + document_base_url, .. + } => Some( + Url::parse(document_base_url).map_err(|_| InvocationAdapterError { + code: "invalid_openapi_server", + message: "The OpenAPI operation has no usable server URL.", + })?, + ), + }; + let protocol_request = build_protocol_request_with_base( + binding, + arguments, + &credential.credentials, + document_base_url.as_ref(), + ) + .map_err(invocation_adapter_error)?; + let method = Method::from_bytes(protocol_request.method.as_bytes()).map_err(|_| { + InvocationAdapterError { + code: "invalid_tool_arguments", + message: "The tool arguments are invalid.", + } + })?; + let mut request = OutboundRequest::new(method, protocol_request.url); + for (name, value) in protocol_request.headers { + let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| InvocationAdapterError { + code: "forbidden_tool_header", + message: "Tool arguments cannot set a protected HTTP header.", + })?; + let value = HeaderValue::from_str(&value).map_err(|_| InvocationAdapterError { + code: "invalid_tool_arguments", + message: "The tool arguments are invalid.", + })?; + request.headers.insert(name, value); + } + request.body = protocol_request.body; + Ok(OpenApiInvocationPlan { + request, + policy: OutboundPolicy { + allow_private_networks: source_configuration + .get("allowPrivateNetwork") + .and_then(Value::as_bool) + .unwrap_or(false), + ..OutboundPolicy::default() + }, + }) +} + +fn invocation_adapter_error(error: OpenApiInvocationError) -> InvocationAdapterError { + match error { + OpenApiInvocationError::InvalidArguments | OpenApiInvocationError::InvalidArgument(_) => { + InvocationAdapterError { + code: "invalid_tool_arguments", + message: "The tool arguments are invalid.", + } + } + OpenApiInvocationError::MissingArgument(_) => InvocationAdapterError { + code: "missing_tool_argument", + message: "A required tool argument is missing.", + }, + OpenApiInvocationError::UnsatisfiedSecurity => InvocationAdapterError { + code: "missing_source_credentials", + message: "No configured credential satisfies this operation.", + }, + OpenApiInvocationError::InvalidCredential(_) => InvocationAdapterError { + code: "unsupported_authentication", + message: "This operation requires an authentication method that is not configured.", + }, + OpenApiInvocationError::InvalidCredentialConfiguration => InvocationAdapterError { + code: "invalid_source_credentials", + message: "The source credential state is invalid.", + }, + OpenApiInvocationError::InvalidHeader(_) => InvocationAdapterError { + code: "forbidden_tool_header", + message: "Tool arguments cannot set a protected HTTP header.", + }, + OpenApiInvocationError::InvalidUrl => InvocationAdapterError { + code: "invalid_openapi_server", + message: "The OpenAPI operation has no usable server URL.", + }, + } +} + +fn import_error(request_id: &RequestId, error: ImportError) -> ApiError { + match error { + ImportError::OpenApi(error) => ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + openapi_error_code(&error), + error.to_string(), + ), + ImportError::Outbound(error) => outbound_error(request_id, error), + ImportError::TooLarge => ApiError::new( + request_id, + StatusCode::PAYLOAD_TOO_LARGE, + "openapi_document_too_large", + "The OpenAPI document exceeds the allowed size.", + ), + ImportError::InlineServerRequired => ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "inline_openapi_server_required", + "Inline OpenAPI documents must define an absolute HTTP or HTTPS server URL.", + ), + } +} + +fn openapi_error_code(error: &OpenApiError) -> &'static str { + match error { + OpenApiError::Parse => "invalid_openapi_document", + OpenApiError::UnsupportedVersion => "unsupported_openapi_version", + OpenApiError::ExternalReference(_) => "external_reference_unsupported", + OpenApiError::ReferenceNotFound(_) => "openapi_reference_not_found", + OpenApiError::ReferenceCycle(_) => "openapi_reference_cycle", + OpenApiError::ReferenceDepth => "openapi_reference_depth", + OpenApiError::LimitExceeded { code } => code, + OpenApiError::InvalidDocument(_) | OpenApiError::InvalidOperation { .. } => { + "invalid_openapi_document" + } + } +} + +fn outbound_error(request_id: &RequestId, error: OutboundError) -> ApiError { + let status = match error { + OutboundError::PrivateAddress | OutboundError::ForbiddenAddress => StatusCode::FORBIDDEN, + OutboundError::ResponseBodyTooLarge + | OutboundError::ResponseHeadersTooLarge + | OutboundError::RequestBodyTooLarge + | OutboundError::RequestHeadersTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + OutboundError::Timeout => StatusCode::GATEWAY_TIMEOUT, + OutboundError::Connection + | OutboundError::Request + | OutboundError::DnsResolution + | OutboundError::UpstreamStatus { .. } => StatusCode::BAD_GATEWAY, + _ => StatusCode::BAD_REQUEST, + }; + ApiError::new( + request_id, + status, + error.code(), + "The upstream request could not be completed safely.", + ) +} + +fn catalog_error(request_id: &RequestId, error: CatalogError) -> ApiError { + match error { + CatalogError::Validation { code, message } => { + ApiError::new(request_id, StatusCode::BAD_REQUEST, code, message) + } + CatalogError::NotFound { entity: "source" } => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "source_not_found", + "The requested source does not exist.", + ), + CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "tool_not_found", + "The requested tool does not exist.", + ), + CatalogError::ToolDisabled { .. } => ApiError::new( + request_id, + StatusCode::FORBIDDEN, + "tool_disabled", + "The requested tool is disabled.", + ), + CatalogError::RevisionConflict { .. } => revision_conflict(request_id), + error => ApiError::internal_logged(request_id, error), + } +} + +fn revision_conflict(request_id: &RequestId) -> ApiError { + ApiError::new( + request_id, + StatusCode::CONFLICT, + "revision_conflict", + "The source changed. Refresh and retry the update.", + ) +} diff --git a/src/api/protocols.rs b/src/api/protocols.rs new file mode 100644 index 000000000..861ae0315 --- /dev/null +++ b/src/api/protocols.rs @@ -0,0 +1,483 @@ +use std::{collections::BTreeMap, time::Instant}; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Extension, State, rejection::JsonRejection}, + http::{HeaderMap, StatusCode, header}, + routing::post, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use super::{ + AdminMutation, ApiError, AppState, GatewayAuthentication, RequestId, openapi, parse_json, +}; +use crate::{ + catalog::{ + CatalogError, InvocationLease, InvocationLookup, InvocationRevisionToken, NewRequestLog, + RequestOutcome, RequestSurface, SourceKind, ToolBinding, + }, + outbound::{HardenedHttpClient, OutboundError}, + unix_timestamp, +}; + +const MAX_SOURCE_BODY_BYTES: usize = 16 * 1024 * 1024 + 64 * 1024; +const MAX_ARGUMENT_BYTES: usize = 8 * 1024 * 1024; +const MAX_INVOKE_BODY_BYTES: usize = MAX_ARGUMENT_BYTES + 64 * 1024; + +pub(super) fn router() -> Router { + Router::new() + .route("/api/v1/sources", post(create_source)) + .layer(DefaultBodyLimit::max(MAX_SOURCE_BODY_BYTES)) + .merge( + Router::new() + .route("/api/v1/gateway/tools/invoke", post(invoke)) + .layer(DefaultBodyLimit::max(MAX_INVOKE_BODY_BYTES)), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateSourceRequest { + kind: SourceKind, + #[serde(flatten)] + protocol: Map, +} + +async fn create_source( + Extension(request_id): Extension, + State(state): State, + AdminMutation(admin_id): AdminMutation, + payload: Result, JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let Json(payload) = parse_json(&request_id, payload)?; + match payload.kind { + SourceKind::Openapi => { + let request = + serde_json::from_value(Value::Object(payload.protocol)).map_err(|_| { + ApiError::new( + &request_id, + StatusCode::BAD_REQUEST, + "invalid_json", + "The request body must be valid JSON with the expected fields.", + ) + })?; + let source = openapi::create_source(&state, &request_id, admin_id, request).await?; + Ok((StatusCode::CREATED, Json(source))) + } + SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => Err(ApiError::new( + &request_id, + StatusCode::BAD_REQUEST, + "unsupported_source_kind", + "This source protocol is not supported yet.", + )), + } +} + +#[derive(Deserialize)] +struct InvokeRequest { + path: String, + #[serde(default = "empty_object")] + arguments: Value, +} + +fn empty_object() -> Value { + Value::Object(Map::new()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InvokeResponse { + ok: bool, + data: Option, + error: Option, + http: InvokeHttp, +} + +#[derive(Serialize)] +struct InvokeError { + code: &'static str, + message: &'static str, +} + +#[derive(Serialize)] +struct InvokeHttp { + status: u16, + headers: BTreeMap, + truncated: bool, +} + +pub(super) struct InvocationAdapterError { + pub code: &'static str, + pub message: &'static str, +} + +async fn invoke( + Extension(request_id): Extension, + State(state): State, + GatewayAuthentication(identity): GatewayAuthentication, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let started = Instant::now(); + let Json(payload) = match parse_json(&request_id, payload) { + Ok(payload) => payload, + Err(error) => { + record_invocation_attempt( + &state, + &request_id, + &identity.token_id, + None, + None, + "tools.invoke", + started, + RequestOutcome::Failed, + Some(error.code), + ); + return Err(error); + } + }; + if serde_json::to_vec(&payload.arguments) + .is_ok_and(|encoded| encoded.len() > MAX_ARGUMENT_BYTES) + { + record_invocation_attempt( + &state, + &request_id, + &identity.token_id, + None, + None, + &payload.path, + started, + RequestOutcome::Failed, + Some("arguments_too_large"), + ); + return Err(ApiError::new( + &request_id, + StatusCode::PAYLOAD_TOO_LARGE, + "arguments_too_large", + "Tool arguments exceed the allowed size.", + )); + } + + let lease = match state.catalog.prepare_invocation(&payload.path).await { + Ok(lease) => lease, + Err(error) => { + record_invocation_attempt( + &state, + &request_id, + &identity.token_id, + None, + None, + &payload.path, + started, + if matches!(error, CatalogError::ToolDisabled { .. }) { + RequestOutcome::Denied + } else { + RequestOutcome::Failed + }, + Some(catalog_log_code(&error)), + ); + return Err(catalog_error(&request_id, error)); + } + }; + let plan = match dispatch(&lease, &payload.arguments) { + Ok(plan) => plan, + Err(error) => { + record_invocation( + &state, + &request_id, + &identity.token_id, + &lease.lookup, + started, + RequestOutcome::Failed, + Some(error.code), + ); + return Err(ApiError::new( + &request_id, + StatusCode::BAD_REQUEST, + error.code, + error.message, + )); + } + }; + if !lease.arguments_are_valid(&payload.arguments) { + record_invocation( + &state, + &request_id, + &identity.token_id, + &lease.lookup, + started, + RequestOutcome::Failed, + Some("invalid_tool_arguments"), + ); + return Err(ApiError::new( + &request_id, + StatusCode::BAD_REQUEST, + "invalid_tool_arguments", + "The tool arguments do not match the imported input schema.", + )); + } + if lease.lookup.requires_approval { + let pending = PendingApproval { + revisions: lease.revisions.clone(), + }; + record_invocation( + &state, + &request_id, + &identity.token_id, + &lease.lookup, + started, + RequestOutcome::PendingApproval, + Some("approval_required"), + ); + return Err(pending.into_api_error(&request_id)); + } + + let response = match HardenedHttpClient::new(plan.policy) + .execute(plan.request) + .await + { + Ok(response) => { + let succeeded = response.status.is_success(); + let data = response_data(&response.headers, &response.body); + record_invocation( + &state, + &request_id, + &identity.token_id, + &lease.lookup, + started, + if succeeded { + RequestOutcome::Succeeded + } else { + RequestOutcome::Failed + }, + (!succeeded).then_some("upstream_http_error"), + ); + InvokeResponse { + ok: succeeded, + data: succeeded.then_some(data), + error: (!succeeded).then_some(InvokeError { + code: "upstream_http_error", + message: "The upstream API returned an error response.", + }), + http: InvokeHttp { + status: response.status.as_u16(), + headers: safe_response_headers(&response.headers), + truncated: false, + }, + } + } + Err(error) => { + record_invocation( + &state, + &request_id, + &identity.token_id, + &lease.lookup, + started, + RequestOutcome::Failed, + Some(error.code()), + ); + return Err(outbound_error(&request_id, error)); + } + }; + drop(lease); + Ok(Json(response)) +} + +fn dispatch( + lease: &InvocationLease, + arguments: &Value, +) -> Result { + match &lease.binding { + ToolBinding::OpenapiV1(binding) => openapi::invocation_plan( + binding, + &lease.source_configuration, + lease.credential.as_ref(), + arguments, + ), + } +} + +struct PendingApproval { + revisions: InvocationRevisionToken, +} + +impl PendingApproval { + fn into_api_error(self, request_id: &RequestId) -> ApiError { + tracing::debug!( + request_id = %request_id.0, + source_id = %self.revisions.source_id, + tool_id = %self.revisions.tool_id, + source_revision = self.revisions.source_revision, + catalog_revision = self.revisions.catalog_revision, + tool_revision = self.revisions.tool_revision, + binding_revision = self.revisions.binding_revision, + credential_revision = self.revisions.credential_revision, + "invocation requires approval against a catalog revision token" + ); + ApiError::new( + request_id, + StatusCode::CONFLICT, + "approval_required", + "This tool requires interactive approval before it can run.", + ) + } +} + +fn response_data(headers: &HeaderMap, body: &[u8]) -> Value { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if (content_type.contains("/json") || content_type.contains("+json")) + && let Ok(value) = serde_json::from_slice(body) + { + value + } else if let Ok(value) = std::str::from_utf8(body) { + Value::String(value.to_owned()) + } else { + json!({ "encoding": "base64", "data": STANDARD.encode(body) }) + } +} + +fn safe_response_headers(headers: &HeaderMap) -> BTreeMap { + [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::RETRY_AFTER, + ] + .into_iter() + .filter_map(|name| { + headers + .get(&name) + .and_then(|value| value.to_str().ok()) + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect() +} + +fn record_invocation( + state: &AppState, + request_id: &RequestId, + token_id: &str, + lookup: &InvocationLookup, + started: Instant, + outcome: RequestOutcome, + error_code: Option<&str>, +) { + record_invocation_attempt( + state, + request_id, + token_id, + Some(lookup.source_id.clone()), + Some(lookup.tool_id.clone()), + &lookup.callable_path, + started, + outcome, + error_code, + ); +} + +#[allow(clippy::too_many_arguments)] +fn record_invocation_attempt( + state: &AppState, + request_id: &RequestId, + token_id: &str, + source_id: Option, + tool_id: Option, + path: &str, + started: Instant, + outcome: RequestOutcome, + error_code: Option<&str>, +) { + let mut path_snapshot = if path.starts_with("tools.") { + path.to_owned() + } else { + format!("tools.{path}") + }; + if path_snapshot.len() > 512 || path_snapshot.contains('\0') { + path_snapshot = "tools.invoke".to_owned(); + } + state.request_logs.try_record(NewRequestLog { + request_id: request_id.0.clone(), + actor_api_token_id: Some(token_id.to_owned()), + surface: RequestSurface::Gateway, + source_id, + tool_id, + path_snapshot: Some(path_snapshot), + outcome, + error_code: error_code.map(str::to_owned), + duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + approval_id: None, + created_at: unix_timestamp(), + }); +} + +fn catalog_log_code(error: &CatalogError) -> &'static str { + match error { + CatalogError::Validation { code, .. } => code, + CatalogError::NotFound { entity: "source" } => "source_not_found", + CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => "tool_not_found", + CatalogError::ToolDisabled { .. } => "tool_disabled", + CatalogError::RevisionConflict { .. } => "revision_conflict", + CatalogError::Database(_) + | CatalogError::Crypto(_) + | CatalogError::Json(_) + | CatalogError::CorruptData(_) => "internal_error", + } +} + +fn catalog_error(request_id: &RequestId, error: CatalogError) -> ApiError { + match error { + CatalogError::Validation { code, message } => { + ApiError::new(request_id, StatusCode::BAD_REQUEST, code, message) + } + CatalogError::NotFound { entity: "source" } => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "source_not_found", + "The requested source does not exist.", + ), + CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "tool_not_found", + "The requested tool does not exist.", + ), + CatalogError::ToolDisabled { .. } => ApiError::new( + request_id, + StatusCode::FORBIDDEN, + "tool_disabled", + "The requested tool is disabled.", + ), + CatalogError::RevisionConflict { .. } => ApiError::new( + request_id, + StatusCode::CONFLICT, + "revision_conflict", + "The source changed. Refresh and retry the update.", + ), + error => ApiError::internal_logged(request_id, error), + } +} + +fn outbound_error(request_id: &RequestId, error: OutboundError) -> ApiError { + let status = match error { + OutboundError::PrivateAddress | OutboundError::ForbiddenAddress => StatusCode::FORBIDDEN, + OutboundError::ResponseBodyTooLarge + | OutboundError::ResponseHeadersTooLarge + | OutboundError::RequestBodyTooLarge + | OutboundError::RequestHeadersTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + OutboundError::Timeout => StatusCode::GATEWAY_TIMEOUT, + OutboundError::Connection + | OutboundError::Request + | OutboundError::DnsResolution + | OutboundError::UpstreamStatus { .. } => StatusCode::BAD_GATEWAY, + _ => StatusCode::BAD_REQUEST, + }; + ApiError::new( + request_id, + status, + error.code(), + "The upstream request could not be completed safely.", + ) +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index 34e8c5519..353562048 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -1,3 +1,4 @@ +mod schema; mod search; mod store; @@ -8,6 +9,7 @@ use serde_json::Value; use thiserror::Error; use crate::crypto::CryptoError; +use crate::openapi::OpenApiBinding; pub use store::CatalogStore; @@ -238,6 +240,12 @@ pub struct StagedTool { pub intrinsic_mode: ToolMode, } +#[derive(Clone, Debug)] +pub struct InitialCatalogSnapshot { + pub artifacts: Vec, + pub tools: Vec, +} + #[derive(Clone, Debug)] pub struct CatalogSnapshot { pub expected_source_revision: i64, @@ -253,6 +261,67 @@ pub struct StagedArtifact { pub content: Value, } +#[derive(Clone, Debug)] +pub struct StagedToolBinding { + pub stable_key: String, + pub binding: ToolBinding, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", content = "definition", rename_all = "snake_case")] +pub enum ToolBinding { + OpenapiV1(OpenApiBinding), +} + +impl ToolBinding { + pub const fn protocol(&self) -> &'static str { + match self { + Self::OpenapiV1(_) => "openapi", + } + } + + pub const fn version(&self) -> i64 { + match self { + Self::OpenapiV1(_) => 1, + } + } + + pub fn openapi(&self) -> Option<&OpenApiBinding> { + match self { + Self::OpenapiV1(binding) => Some(binding), + } + } + + pub(crate) fn decode( + protocol: &str, + version: i64, + definition_json: &str, + ) -> Result { + match (protocol, version) { + ("openapi", 1) => { + let binding: OpenApiBinding = serde_json::from_str(definition_json)?; + if binding.version != 1 { + return Err(CatalogError::CorruptData( + "unsupported OpenAPI binding version", + )); + } + Ok(Self::OpenapiV1(binding)) + } + _ => Err(CatalogError::CorruptData( + "unknown tool binding protocol or version", + )), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoredToolBinding { + pub tool_id: String, + pub source_id: String, + pub revision: i64, + pub binding: ToolBinding, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CatalogSyncResult { @@ -394,6 +463,62 @@ pub struct InvocationLookup { pub requires_approval: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvocationRevisionToken { + pub source_id: String, + pub tool_id: String, + pub source_revision: i64, + pub catalog_revision: i64, + pub tool_revision: i64, + pub binding_revision: i64, + pub credential_revision: Option, +} + +pub struct InvocationLease { + pub(crate) lookup: InvocationLookup, + pub(crate) revisions: InvocationRevisionToken, + pub(crate) binding: ToolBinding, + pub(crate) input_schema: Value, + pub(crate) input_validator: jsonschema::Validator, + pub(crate) source_configuration: serde_json::Map, + pub(crate) credential: Option, + pub(crate) _guard: tokio::sync::OwnedRwLockReadGuard<()>, +} + +impl InvocationLease { + pub fn lookup(&self) -> &InvocationLookup { + &self.lookup + } + + pub fn revisions(&self) -> &InvocationRevisionToken { + &self.revisions + } + + pub fn binding(&self) -> &ToolBinding { + &self.binding + } + + pub fn input_schema(&self) -> &Value { + &self.input_schema + } + + pub fn arguments_are_valid(&self, arguments: &Value) -> bool { + self.input_validator.is_valid(arguments) + } + + pub fn source_configuration(&self) -> &serde_json::Map { + &self.source_configuration + } + + pub fn credential(&self) -> Option<&StoredCredential> { + self.credential.as_ref() + } +} + +impl Drop for InvocationLease { + fn drop(&mut self) {} +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CredentialPayload { diff --git a/src/catalog/schema.rs b/src/catalog/schema.rs new file mode 100644 index 000000000..0b84479b0 --- /dev/null +++ b/src/catalog/schema.rs @@ -0,0 +1,103 @@ +use std::error::Error; + +use jsonschema::{Draft, PatternOptions, Retrieve, Uri, Validator}; +use serde_json::Value; + +const REGEX_SIZE_LIMIT: usize = 1024 * 1024; +const REGEX_DFA_SIZE_LIMIT: usize = 1024 * 1024; + +#[derive(Debug)] +struct NoExternalSchemas; + +impl Retrieve for NoExternalSchemas { + fn retrieve(&self, uri: &Uri) -> Result> { + Err(format!("external JSON Schema reference is disabled: {uri}").into()) + } +} + +pub(super) fn compile(schema: &Value) -> Result { + jsonschema::options() + .with_draft(Draft::Draft202012) + .with_retriever(NoExternalSchemas) + .with_pattern_options( + PatternOptions::regex() + .size_limit(REGEX_SIZE_LIMIT) + .dfa_size_limit(REGEX_DFA_SIZE_LIMIT), + ) + .should_validate_formats(false) + .build(schema) + .map_err(|_| ()) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::compile; + + #[test] + fn validates_full_argument_constraints_and_openapi_nullable() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "required": ["name", "count", "mode", "tags", "maybe"], + "properties": { + "name": { "type": "string", "pattern": "^[A-Z][a-z]+$" }, + "count": { "type": "integer" }, + "mode": { "enum": ["safe", "fast"] }, + "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "maybe": { "type": ["string", "null"] } + } + }); + let validator = compile(&schema).expect("schema should compile"); + assert!(validator.is_valid(&json!({ + "name": "Ada", "count": 1.0, "mode": "safe", "tags": ["a", "b"], "maybe": null + }))); + assert!(!validator.is_valid(&json!({ + "name": "ada", "count": 1, "mode": "safe", "tags": ["a", "b"], "maybe": null + }))); + assert!(!validator.is_valid(&json!({ + "name": "Ada", "count": 1.5, "mode": "safe", "tags": ["a", "b"], "maybe": null + }))); + assert!(!validator.is_valid(&json!({ + "name": "Ada", "count": 1, "mode": "other", "tags": ["a", "b"], "maybe": null + }))); + assert!(!validator.is_valid(&json!({ + "name": "Ada", "count": 1, "mode": "safe", "tags": ["a", "a"], "maybe": null + }))); + assert!(!validator.is_valid(&json!({ + "name": "Ada", "count": 1, "mode": "safe", "tags": ["a"], "maybe": null, "extra": true + }))); + } + + #[test] + fn rejects_external_retrieval_and_handles_bounded_depth() { + assert!(compile(&json!({ "$ref": "https://schemas.example.test/external.json" })).is_err()); + + let mut schema = json!({ "type": "string" }); + let mut instance = json!("leaf"); + for _ in 0..64 { + schema = json!({ "type": "array", "items": schema, "maxItems": 1 }); + instance = json!([instance]); + } + let validator = compile(&schema).expect("bounded imported schema depth should compile"); + assert!(validator.is_valid(&instance)); + } + + #[test] + fn nullable_type_union_does_not_bypass_enum_constraints() { + let without_null = compile(&json!({ + "type": ["string", "null"], + "enum": ["allowed"] + })) + .expect("schema should compile"); + assert!(!without_null.is_valid(&Value::Null)); + + let with_null = compile(&json!({ + "type": ["string", "null"], + "enum": ["allowed", null] + })) + .expect("schema should compile"); + assert!(with_null.is_valid(&Value::Null)); + } +} diff --git a/src/catalog/store.rs b/src/catalog/store.rs index fb866a211..58b811ad4 100644 --- a/src/catalog/store.rs +++ b/src/catalog/store.rs @@ -7,16 +7,17 @@ use std::{ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use serde_json::{Map, Value, json}; use sqlx::{FromRow, SqlitePool, Transaction}; -use tokio::sync::{Mutex, Semaphore}; +use tokio::sync::{RwLock, Semaphore}; use uuid::Uuid; use super::{ ArtifactKind, AuditContext, BulkToolModeResult, CatalogError, CatalogSnapshot, CatalogSyncResult, CreateSource, CredentialPayload, DEFAULT_PAGE_LIMIT, DescribedTool, - DiscoveryPage, InvocationLookup, ListToolsFilter, MAX_PAGE_LIMIT, NewRequestLog, - RequestLogPage, RequestLogRecord, RequestOutcome, RequestSurface, SourceHealth, SourceKind, - SourceRecord, StoredCredential, ToolMode, ToolPage, ToolRecord, ToolSummary, UpdateSource, - effective_mode, search, + DiscoveryPage, InitialCatalogSnapshot, InvocationLease, InvocationLookup, + InvocationRevisionToken, ListToolsFilter, MAX_PAGE_LIMIT, NewRequestLog, RequestLogPage, + RequestLogRecord, RequestOutcome, RequestSurface, SourceHealth, SourceKind, SourceRecord, + StagedToolBinding, StoredCredential, StoredToolBinding, ToolBinding, ToolMode, ToolPage, + ToolRecord, ToolSummary, UpdateSource, effective_mode, search, }; use crate::{crypto::Keyring, unix_timestamp}; @@ -56,7 +57,7 @@ const MAX_CATALOG_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; pub struct CatalogStore { pool: SqlitePool, keyring: Keyring, - write_lock: Arc>, + mutation_lock: Arc>, request_log_writes: Arc, } @@ -140,6 +141,31 @@ struct RequestLogRow { created_at: i64, } +#[derive(FromRow)] +struct InvocationRow { + tool_id: String, + source_id: String, + source_kind: String, + source_slug: String, + local_name: String, + present: i64, + intrinsic_mode: String, + tool_mode_override: Option, + source_mode_override: Option, + tool_revision: i64, + source_revision: i64, + catalog_revision: i64, + configuration_json: String, + input_schema_json: String, + binding_protocol: Option, + binding_version: Option, + definition_json: Option, + binding_revision: Option, + credential_schema_version: Option, + credential_ciphertext: Option>, + credential_revision: Option, +} + struct PreparedTool { stable_key: String, preferred_name: String, @@ -164,8 +190,27 @@ struct PreparedArtifact { struct PreparedSnapshot { tools: Vec, artifacts: Vec, + payload_bytes: usize, } +enum CatalogApplyKind<'a> { + Initial { + source_kind: SourceKind, + slug: &'a str, + credential_schema_version: u32, + }, + Refresh, +} + +struct PreparedToolBinding { + stable_key: String, + protocol: &'static str, + version: i64, + definition_json: String, +} + +type PreparedToolBindings = Vec; + #[derive(Clone, Copy)] struct PayloadLimits { artifact_count: usize, @@ -226,7 +271,7 @@ impl CatalogStore { Self { pool, keyring, - write_lock: Arc::new(Mutex::new(())), + mutation_lock: Arc::new(RwLock::new(())), request_log_writes: Arc::new(Semaphore::new(1)), } } @@ -243,6 +288,147 @@ impl CatalogStore { ) } + pub async fn create_source_with_catalog( + &self, + input: CreateSource, + credential: &CredentialPayload, + snapshot: InitialCatalogSnapshot, + bindings: Vec, + audit: AuditContext<'_>, + ) -> Result<(SourceRecord, CatalogSyncResult), CatalogError> { + if input.kind != SourceKind::Openapi { + return Err(validation( + "invalid_source_kind", + "Atomic imported-source creation currently supports OpenAPI sources only.", + )); + } + let display_name = validate_text( + "invalid_source_name", + "Source names must contain between 1 and 200 characters.", + &input.display_name, + 200, + )?; + let description = validate_optional_text( + "invalid_source_description", + "Source descriptions may contain at most 2000 characters.", + input.description.as_deref(), + 2000, + )?; + if credential.schema_version == 0 { + return Err(validation( + "invalid_credential_schema", + "Credential schema versions must be positive.", + )); + } + let (prepared, prepared_bindings) = + prepare_initial_snapshot_and_bindings(snapshot, bindings)?; + if prepared_bindings + .iter() + .map(|binding| &binding.stable_key) + .collect::>() + != prepared + .tools + .iter() + .map(|tool| &tool.stable_key) + .collect::>() + { + return Err(validation( + "incomplete_tool_bindings", + "Every staged imported tool must have exactly one binding.", + )); + } + + let source_id = Uuid::new_v4().to_string(); + let configuration_json = serde_json::to_string(&input.configuration)?; + let credential_plaintext = serde_json::to_vec(&credential.payload)?; + let credential_ciphertext = + self.keyring + .encrypt(CREDENTIAL_PURPOSE, &source_id, &credential_plaintext)?; + let base_slug = normalize_source_slug(&input.preferred_slug); + let now = unix_timestamp(); + let _write = self.mutation_lock.write().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + + let mut used_slugs = sqlx::query_scalar::<_, String>("SELECT slug FROM sources") + .fetch_all(&mut *transaction) + .await? + .into_iter() + .collect::>(); + used_slugs.extend(RESERVED_SOURCE_SLUGS.into_iter().map(str::to_owned)); + let slug = NameAllocator::new(used_slugs).allocate(&base_slug, 63, '_'); + let search_short_grams = search::short_gram_document(&[&slug]); + sqlx::query( + "INSERT INTO sources \ + (id, kind, slug, search_short_grams, display_name, description, configuration_json, \ + health_status, revision, catalog_revision, created_at, updated_at, last_refreshed_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, 'healthy', 1, 1, ?, ?, ?)", + ) + .bind(&source_id) + .bind(input.kind.as_str()) + .bind(&slug) + .bind(search_short_grams) + .bind(display_name) + .bind(description) + .bind(configuration_json) + .bind(now) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO source_credentials \ + (source_id, schema_version, payload_ciphertext, revision, created_at, updated_at) \ + VALUES (?, ?, ?, 0, ?, ?)", + ) + .bind(&source_id) + .bind(i64::from(credential.schema_version)) + .bind(credential_ciphertext) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + + apply_artifacts(&mut transaction, &source_id, &prepared.artifacts, now).await?; + let missing = apply_tools(&mut transaction, &source_id, &prepared.tools, now).await?; + debug_assert!(missing.is_empty()); + apply_tool_bindings(&mut transaction, &source_id, &prepared_bindings, now).await?; + rebuild_search_indexes(&mut transaction, &source_id).await?; + + let source_path = format!("tools.{slug}"); + let (source_revision, catalog_revision, global_revision) = finalize_catalog_apply( + &mut transaction, + audit, + &source_id, + &source_path, + CatalogApplyKind::Initial { + source_kind: input.kind, + slug: &slug, + credential_schema_version: credential.schema_version, + }, + prepared.tools.len(), + prepared.artifacts.len(), + 0, + now, + ) + .await?; + + let source = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(&source_id) + .fetch_one(&mut *transaction) + .await? + .try_into()?; + transaction.commit().await?; + let sync = CatalogSyncResult { + source_id, + source_revision, + catalog_revision, + global_revision, + active_tool_count: prepared.tools.len(), + tombstoned_tool_count: 0, + }; + Ok((source, sync)) + } + pub async fn create_source( &self, input: CreateSource, @@ -264,7 +450,7 @@ impl CatalogStore { let source_id = Uuid::new_v4().to_string(); let configuration_json = serde_json::to_string(&input.configuration)?; let now = unix_timestamp(); - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let mut used_slugs = sqlx::query_scalar::<_, String>("SELECT slug FROM sources") .fetch_all(&mut *transaction) @@ -353,7 +539,7 @@ impl CatalogStore { 2000, )?; let configuration_json = serde_json::to_string(&input.configuration)?; - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let now = unix_timestamp(); let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let changed = sqlx::query( @@ -401,7 +587,7 @@ impl CatalogStore { source_id: &str, audit: AuditContext<'_>, ) -> Result<(), CatalogError> { - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let source = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) .bind(source_id) @@ -448,7 +634,7 @@ impl CatalogStore { expected_revision: i64, audit: AuditContext<'_>, ) -> Result { - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let now = unix_timestamp(); let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let changed = sqlx::query( @@ -507,7 +693,7 @@ impl CatalogStore { .keyring .encrypt(CREDENTIAL_PURPOSE, source_id, &plaintext)?; let now = unix_timestamp(); - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; ensure_source_exists(&mut transaction, source_id).await?; let actual_revision = sqlx::query_scalar::<_, i64>( @@ -607,20 +793,179 @@ impl CatalogStore { .transpose() } + pub async fn delete_credential( + &self, + source_id: &str, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result<(), CatalogError> { + let now = unix_timestamp(); + let _write = self.mutation_lock.write().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + ensure_source_exists(&mut transaction, source_id).await?; + let changed = + sqlx::query("DELETE FROM source_credentials WHERE source_id = ? AND revision = ?") + .bind(source_id) + .bind(expected_revision) + .execute(&mut *transaction) + .await? + .rows_affected(); + if changed == 0 { + let actual = sqlx::query_scalar::<_, i64>( + "SELECT revision FROM source_credentials WHERE source_id = ?", + ) + .bind(source_id) + .fetch_optional(&mut *transaction) + .await? + .unwrap_or(-1); + return Err(CatalogError::RevisionConflict { + scope: "credential", + expected: expected_revision, + actual, + }); + } + increment_source_and_global(&mut transaction, source_id, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.credential_deleted", + Some(source_id), + None, + Some(&source_path), + json!({}), + now, + ) + .await?; + transaction.commit().await?; + Ok(()) + } + + #[cfg(test)] + pub(crate) async fn replace_tool_bindings( + &self, + source_id: &str, + bindings: Vec, + ) -> Result<(), CatalogError> { + let prepared = prepare_tool_bindings(bindings)?; + + let _write = self.mutation_lock.write().await; + let now = unix_timestamp(); + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let source_kind = source_kind(&mut transaction, source_id).await?; + if source_kind != SourceKind::Openapi { + return Err(validation( + "invalid_source_kind", + "OpenAPI tool bindings may only be stored for OpenAPI sources.", + )); + } + let active_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tools WHERE source_id = ? AND present = 1", + ) + .bind(source_id) + .fetch_one(&mut *transaction) + .await?; + if usize::try_from(active_count).ok() != Some(prepared.len()) { + return Err(validation( + "incomplete_tool_bindings", + "Every active imported tool must have exactly one binding.", + )); + } + for binding in prepared { + let changed = sqlx::query( + "INSERT INTO tool_bindings (tool_id, protocol, binding_version, definition_json, revision, created_at, updated_at) \ + SELECT id, ?, ?, ?, 0, ?, ? FROM tools \ + WHERE source_id = ? AND stable_key = ? AND present = 1 \ + ON CONFLICT(tool_id) DO UPDATE SET protocol = excluded.protocol, \ + binding_version = excluded.binding_version, \ + definition_json = excluded.definition_json, revision = tool_bindings.revision + 1, \ + updated_at = excluded.updated_at", + ) + .bind(binding.protocol) + .bind(binding.version) + .bind(binding.definition_json) + .bind(now) + .bind(now) + .bind(source_id) + .bind(binding.stable_key) + .execute(&mut *transaction) + .await? + .rows_affected(); + if changed != 1 { + return Err(validation( + "invalid_tool_binding", + "A tool binding does not match an active imported tool.", + )); + } + } + sqlx::query( + "DELETE FROM tool_bindings WHERE tool_id IN (\ + SELECT id FROM tools WHERE source_id = ? AND present = 0)", + ) + .bind(source_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn tool_binding(&self, tool_id: &str) -> Result { + let row = sqlx::query_as::<_, (String, String, String, String, i64, String, i64)>( + "SELECT tool_bindings.tool_id, tools.source_id, sources.kind, \ + tool_bindings.protocol, tool_bindings.binding_version, tool_bindings.definition_json, \ + tool_bindings.revision FROM tool_bindings \ + JOIN tools ON tools.id = tool_bindings.tool_id \ + JOIN sources ON sources.id = tools.source_id \ + WHERE tool_bindings.tool_id = ? AND tools.present = 1", + ) + .bind(tool_id) + .fetch_optional(&self.pool) + .await? + .ok_or(CatalogError::NotFound { + entity: "tool binding", + })?; + let binding = ToolBinding::decode(&row.3, row.4, &row.5)?; + if !matches!( + (&binding, SourceKind::from_str(&row.2)?), + (ToolBinding::OpenapiV1(_), SourceKind::Openapi) + ) { + return Err(CatalogError::CorruptData( + "tool binding does not match source kind", + )); + } + Ok(StoredToolBinding { + tool_id: row.0, + source_id: row.1, + revision: row.6, + binding, + }) + } + pub async fn sync_catalog( &self, source_id: &str, snapshot: CatalogSnapshot, audit: AuditContext<'_>, + ) -> Result { + self.sync_catalog_with_bindings(source_id, snapshot, Vec::new(), audit) + .await + } + + pub async fn sync_catalog_with_bindings( + &self, + source_id: &str, + snapshot: CatalogSnapshot, + bindings: Vec, + audit: AuditContext<'_>, ) -> Result { let expected_source_revision = snapshot.expected_source_revision; let expected_credential_revision = snapshot.expected_credential_revision; - let prepared = prepare_snapshot(snapshot)?; + let (prepared, prepared_bindings) = prepare_snapshot_and_bindings(snapshot, bindings)?; let now = unix_timestamp(); - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let actual_source_revision = - sqlx::query_scalar::<_, i64>("SELECT revision FROM sources WHERE id = ?") + let (actual_source_revision, source_kind) = + sqlx::query_as::<_, (i64, String)>("SELECT revision, kind FROM sources WHERE id = ?") .bind(source_id) .fetch_optional(&mut *transaction) .await? @@ -632,6 +977,32 @@ impl CatalogStore { actual: actual_source_revision, }); } + let source_kind = SourceKind::from_str(&source_kind)?; + let binding_keys = prepared_bindings + .iter() + .map(|binding| &binding.stable_key) + .collect::>(); + let staged_tool_keys = prepared + .tools + .iter() + .map(|tool| &tool.stable_key) + .collect::>(); + match source_kind { + SourceKind::Openapi if binding_keys != staged_tool_keys => { + return Err(validation( + "incomplete_tool_bindings", + "Every active OpenAPI tool must have exactly one binding.", + )); + } + SourceKind::Openapi => {} + _ if !prepared_bindings.is_empty() => { + return Err(validation( + "invalid_source_kind", + "OpenAPI tool bindings may only be stored for OpenAPI sources.", + )); + } + _ => {} + } let actual_credential_revision = sqlx::query_scalar::<_, i64>( "SELECT revision FROM source_credentials WHERE source_id = ?", ) @@ -645,227 +1016,29 @@ impl CatalogStore { actual: actual_credential_revision.unwrap_or(-1), }); } - let existing = sqlx::query_as::<_, (String, String, i64)>( - "SELECT stable_key, local_name, present FROM tools WHERE source_id = ?", - ) - .bind(source_id) - .fetch_all(&mut *transaction) - .await?; - let existing_by_key = existing - .into_iter() - .map(|(stable_key, local_name, present)| (stable_key, (local_name, present != 0))) - .collect::>(); - let staged_keys = prepared - .tools - .iter() - .map(|tool| tool.stable_key.clone()) - .collect::>(); - validate_tool_history(&existing_by_key, &staged_keys)?; - - let artifact_keys = prepared - .artifacts - .iter() - .map(|artifact| { - ( - artifact.kind.as_str().to_owned(), - artifact.stable_key.clone(), - ) - }) - .collect::>(); - let existing_artifact_keys = sqlx::query_as::<_, (String, String)>( - "SELECT artifact_kind, stable_key FROM source_artifacts WHERE source_id = ?", - ) - .bind(source_id) - .fetch_all(&mut *transaction) - .await?; - for artifact in &prepared.artifacts { - sqlx::query( - "INSERT INTO source_artifacts \ - (id, source_id, artifact_kind, stable_key, content_json, revision, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, 0, ?, ?) \ - ON CONFLICT(source_id, artifact_kind, stable_key) DO UPDATE SET \ - content_json = excluded.content_json, revision = source_artifacts.revision + 1, \ - updated_at = excluded.updated_at", - ) - .bind(Uuid::new_v4().to_string()) - .bind(source_id) - .bind(artifact.kind.as_str()) - .bind(&artifact.stable_key) - .bind(&artifact.content_json) - .bind(now) - .bind(now) - .execute(&mut *transaction) - .await?; - } - for (kind, stable_key) in existing_artifact_keys { - if !artifact_keys.contains(&(kind.clone(), stable_key.clone())) { - sqlx::query( - "DELETE FROM source_artifacts \ - WHERE source_id = ? AND artifact_kind = ? AND stable_key = ?", - ) - .bind(source_id) - .bind(kind) - .bind(stable_key) - .execute(&mut *transaction) - .await?; - } - } - let used_names = existing_by_key - .values() - .map(|(local_name, _)| local_name.clone()) - .collect::>(); - let mut name_allocator = NameAllocator::new(used_names); - - for tool in &prepared.tools { - let local_name = existing_by_key - .get(&tool.stable_key) - .map(|(local_name, _)| local_name.clone()) - .unwrap_or_else(|| { - let base = normalize_tool_name(&tool.preferred_name); - name_allocator.allocate(&base, 128, '_') - }); - sqlx::query( - "INSERT INTO tools \ - (id, source_id, stable_key, local_name, display_name, description, search_description, \ - search_short_grams, \ - input_schema_json, output_schema_json, input_typescript, output_typescript, \ - typescript_definitions_json, intrinsic_mode, present, revision, \ - created_at, updated_at, last_seen_at, tombstoned_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, NULL) \ - ON CONFLICT(source_id, stable_key) DO UPDATE SET \ - display_name = excluded.display_name, description = excluded.description, \ - search_description = excluded.search_description, \ - search_short_grams = excluded.search_short_grams, \ - input_schema_json = excluded.input_schema_json, \ - output_schema_json = excluded.output_schema_json, \ - input_typescript = excluded.input_typescript, \ - output_typescript = excluded.output_typescript, \ - typescript_definitions_json = excluded.typescript_definitions_json, \ - intrinsic_mode = excluded.intrinsic_mode, present = 1, \ - revision = tools.revision + 1, updated_at = excluded.updated_at, \ - last_seen_at = excluded.last_seen_at, tombstoned_at = NULL", - ) - .bind(Uuid::new_v4().to_string()) - .bind(source_id) - .bind(&tool.stable_key) - .bind(local_name) - .bind(&tool.display_name) - .bind(&tool.description) - .bind(&tool.search_description) - .bind(&tool.search_short_grams) - .bind(&tool.input_schema_json) - .bind(&tool.output_schema_json) - .bind(&tool.input_typescript) - .bind(&tool.output_typescript) - .bind(&tool.typescript_definitions_json) - .bind(tool.intrinsic_mode.as_str()) - .bind(now) - .bind(now) - .bind(now) - .execute(&mut *transaction) - .await?; - } - - let missing = existing_by_key - .iter() - .filter(|(stable_key, (_, present))| *present && !staged_keys.contains(*stable_key)) - .map(|(stable_key, _)| stable_key) - .collect::>(); - for stable_key in &missing { - sqlx::query( - "UPDATE tools SET present = 0, revision = revision + 1, updated_at = ?, \ - tombstoned_at = ? WHERE source_id = ? AND stable_key = ? AND present = 1", - ) - .bind(now) - .bind(now) - .bind(source_id) - .bind(stable_key) - .execute(&mut *transaction) - .await?; - } - - sqlx::query("DELETE FROM tool_search WHERE source_id = ?") - .bind(source_id) - .execute(&mut *transaction) - .await?; - sqlx::query( - "INSERT INTO tool_search \ - (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ - SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ - replace(tools.local_name, '_', ' '), tools.search_description, \ - replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ - FROM tools JOIN sources ON sources.id = tools.source_id \ - WHERE tools.source_id = ? AND tools.present = 1", - ) - .bind(source_id) - .execute(&mut *transaction) - .await?; - sqlx::query("DELETE FROM tool_search_trigram WHERE source_id = ?") - .bind(source_id) - .execute(&mut *transaction) - .await?; - sqlx::query( - "INSERT INTO tool_search_trigram \ - (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ - SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ - replace(tools.local_name, '_', ' '), tools.search_description, \ - replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ - FROM tools JOIN sources ON sources.id = tools.source_id \ - WHERE tools.source_id = ? AND tools.present = 1", - ) - .bind(source_id) - .execute(&mut *transaction) - .await?; - sqlx::query("DELETE FROM tool_search_short WHERE source_id = ?") - .bind(source_id) - .execute(&mut *transaction) - .await?; - sqlx::query( - "INSERT INTO tool_search_short (source_id, tool_id, grams) \ - SELECT tools.source_id, tools.id, \ - sources.search_short_grams || ' ' || tools.search_short_grams \ - FROM tools JOIN sources ON sources.id = tools.source_id \ - WHERE tools.source_id = ? AND tools.present = 1", - ) - .bind(source_id) - .execute(&mut *transaction) - .await?; + apply_artifacts(&mut transaction, source_id, &prepared.artifacts, now).await?; + let missing = apply_tools(&mut transaction, source_id, &prepared.tools, now).await?; + apply_tool_bindings(&mut transaction, source_id, &prepared_bindings, now).await?; + rebuild_search_indexes(&mut transaction, source_id).await?; - let revisions = sqlx::query_as::<_, (i64, i64)>( - "UPDATE sources SET revision = revision + 1, \ - catalog_revision = catalog_revision + 1, health_status = 'healthy', \ - health_error_code = NULL, updated_at = ?, last_refreshed_at = ? \ - WHERE id = ? RETURNING revision, catalog_revision", - ) - .bind(now) - .bind(now) - .bind(source_id) - .fetch_one(&mut *transaction) - .await?; - let global_revision = bump_global_revision(&mut transaction, now).await?; let source_path = source_path_snapshot(&mut transaction, source_id).await?; - insert_audit( + let (source_revision, catalog_revision, global_revision) = finalize_catalog_apply( &mut transaction, audit, - "source.catalog_refreshed", - Some(source_id), - None, - Some(&source_path), - json!({ - "catalogRevision": revisions.1, - "activeToolCount": prepared.tools.len(), - "artifactCount": prepared.artifacts.len(), - "tombstonedToolCount": missing.len(), - "globalRevision": global_revision - }), + source_id, + &source_path, + CatalogApplyKind::Refresh, + prepared.tools.len(), + prepared.artifacts.len(), + missing.len(), now, ) .await?; transaction.commit().await?; Ok(CatalogSyncResult { source_id: source_id.to_owned(), - source_revision: revisions.0, - catalog_revision: revisions.1, + source_revision, + catalog_revision, global_revision, active_tool_count: prepared.tools.len(), tombstoned_tool_count: missing.len(), @@ -945,7 +1118,7 @@ impl CatalogStore { expected_revision: i64, audit: AuditContext<'_>, ) -> Result { - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let now = unix_timestamp(); let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let row = sqlx::query_as::<_, (String, String, String)>( @@ -1007,7 +1180,7 @@ impl CatalogStore { expected_source_revision: i64, audit: AuditContext<'_>, ) -> Result { - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let now = unix_timestamp(); let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let actual_revision = @@ -1079,7 +1252,7 @@ impl CatalogStore { "Select between 1 and 200 tools for a bulk mode change.", )); } - let _write = self.write_lock.lock().await; + let _write = self.mutation_lock.write().await; let now = unix_timestamp(); let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let actual_catalog_revision = @@ -1247,18 +1420,155 @@ impl CatalogStore { path: normalize_sandbox_path(path), }); } - if tool.effective_mode.mode == ToolMode::Disabled { - return Err(CatalogError::ToolDisabled { - path: tool.sandbox_path, - }); + if tool.effective_mode.mode == ToolMode::Disabled { + return Err(CatalogError::ToolDisabled { + path: tool.sandbox_path, + }); + } + Ok(InvocationLookup { + tool_id: tool.id, + source_id: tool.source_id, + callable_path: tool.callable_path, + sandbox_path: tool.sandbox_path, + effective_mode: tool.effective_mode.mode, + requires_approval: tool.effective_mode.mode == ToolMode::Ask, + }) + } + + pub async fn prepare_invocation(&self, path: &str) -> Result { + let Some((source_slug, local_name)) = parse_tool_path(path) else { + return Err(CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + }); + }; + let guard = self.mutation_lock.clone().read_owned().await; + let mut transaction = self.pool.begin().await?; + let row = sqlx::query_as::<_, InvocationRow>(INVOCATION_SELECT_BY_PATH) + .bind(source_slug) + .bind(local_name) + .fetch_optional(&mut *transaction) + .await?; + transaction.commit().await?; + let row = row.ok_or_else(|| CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + })?; + self.invocation_lease(row, guard) + } + + pub async fn revalidate_invocation( + &self, + token: &InvocationRevisionToken, + ) -> Result, CatalogError> { + let guard = self.mutation_lock.clone().read_owned().await; + let mut transaction = self.pool.begin().await?; + let row = sqlx::query_as::<_, InvocationRow>(INVOCATION_SELECT_BY_REVISION) + .bind(&token.tool_id) + .bind(&token.source_id) + .bind(token.source_revision) + .bind(token.catalog_revision) + .bind(token.tool_revision) + .bind(token.binding_revision) + .bind(token.credential_revision) + .fetch_optional(&mut *transaction) + .await?; + transaction.commit().await?; + row.map(|row| self.invocation_lease(row, guard)).transpose() + } + + fn invocation_lease( + &self, + row: InvocationRow, + guard: tokio::sync::OwnedRwLockReadGuard<()>, + ) -> Result { + let sandbox_path = format!("{}.{}", row.source_slug, row.local_name); + if row.present == 0 { + return Err(CatalogError::ToolNotFound { path: sandbox_path }); + } + let mode = effective_mode( + ToolMode::from_str(&row.intrinsic_mode)?, + parse_optional_mode(row.source_mode_override)?, + parse_optional_mode(row.tool_mode_override)?, + ) + .mode; + if mode == ToolMode::Disabled { + return Err(CatalogError::ToolDisabled { path: sandbox_path }); + } + let binding_protocol = row + .binding_protocol + .as_deref() + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let binding_version = row + .binding_version + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let definition_json = row + .definition_json + .as_deref() + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let binding_revision = row + .binding_revision + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let binding = ToolBinding::decode(binding_protocol, binding_version, definition_json)?; + if !matches!( + (&binding, SourceKind::from_str(&row.source_kind)?), + (ToolBinding::OpenapiV1(_), SourceKind::Openapi) + ) { + return Err(CatalogError::CorruptData( + "tool binding does not match source kind", + )); + } + let credential = match ( + row.credential_schema_version, + row.credential_ciphertext, + row.credential_revision, + ) { + (Some(schema_version), Some(ciphertext), Some(revision)) => { + let plaintext = + self.keyring + .decrypt(CREDENTIAL_PURPOSE, &row.source_id, &ciphertext)?; + Some(StoredCredential { + revision, + credential: CredentialPayload { + schema_version: u32::try_from(schema_version) + .map_err(|_| CatalogError::CorruptData("credential schema version"))?, + payload: serde_json::from_slice(&plaintext)?, + }, + }) + } + (None, None, None) => None, + _ => return Err(CatalogError::CorruptData("incomplete source credential")), + }; + let callable_path = format!("tools.{sandbox_path}"); + let lookup = InvocationLookup { + tool_id: row.tool_id.clone(), + source_id: row.source_id.clone(), + callable_path, + sandbox_path, + effective_mode: mode, + requires_approval: mode == ToolMode::Ask, + }; + if row.input_schema_json.len() > MAX_SCHEMA_BYTES { + return Err(CatalogError::CorruptData("tool input schema is too large")); } - Ok(InvocationLookup { - tool_id: tool.id, - source_id: tool.source_id, - callable_path: tool.callable_path, - sandbox_path: tool.sandbox_path, - effective_mode: tool.effective_mode.mode, - requires_approval: tool.effective_mode.mode == ToolMode::Ask, + let input_schema: Value = serde_json::from_str(&row.input_schema_json)?; + let input_validator = super::schema::compile(&input_schema) + .map_err(|()| CatalogError::CorruptData("tool input schema is invalid"))?; + Ok(InvocationLease { + revisions: InvocationRevisionToken { + source_id: row.source_id, + tool_id: row.tool_id, + source_revision: row.source_revision, + catalog_revision: row.catalog_revision, + tool_revision: row.tool_revision, + binding_revision, + credential_revision: row.credential_revision, + }, + lookup, + binding, + input_schema, + input_validator, + source_configuration: serde_json::from_str(&row.configuration_json)?, + credential, + _guard: guard, }) } @@ -1689,19 +1999,157 @@ const TOOL_SUMMARY_SELECT_BY_PATH: &str = "SELECT tools.id, tools.source_id, sou FROM tools JOIN sources ON sources.id = tools.source_id \ WHERE sources.slug = ? AND tools.local_name = ?"; +const INVOCATION_SELECT_BY_PATH: &str = "SELECT tools.id AS tool_id, tools.source_id, \ + sources.kind AS source_kind, sources.slug AS source_slug, tools.local_name, \ + tools.present, tools.intrinsic_mode, \ + tools.mode_override AS tool_mode_override, sources.mode_override AS source_mode_override, \ + tools.revision AS tool_revision, sources.revision AS source_revision, \ + sources.catalog_revision, sources.configuration_json, tools.input_schema_json, \ + tool_bindings.protocol AS binding_protocol, tool_bindings.binding_version, \ + tool_bindings.definition_json, tool_bindings.revision AS binding_revision, \ + source_credentials.schema_version AS credential_schema_version, \ + source_credentials.payload_ciphertext AS credential_ciphertext, \ + source_credentials.revision AS credential_revision \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + LEFT JOIN tool_bindings ON tool_bindings.tool_id = tools.id \ + LEFT JOIN source_credentials ON source_credentials.source_id = sources.id \ + WHERE sources.slug = ? AND tools.local_name = ?"; + +const INVOCATION_SELECT_BY_REVISION: &str = "SELECT tools.id AS tool_id, tools.source_id, \ + sources.kind AS source_kind, sources.slug AS source_slug, tools.local_name, \ + tools.present, tools.intrinsic_mode, \ + tools.mode_override AS tool_mode_override, sources.mode_override AS source_mode_override, \ + tools.revision AS tool_revision, sources.revision AS source_revision, \ + sources.catalog_revision, sources.configuration_json, tools.input_schema_json, \ + tool_bindings.protocol AS binding_protocol, tool_bindings.binding_version, \ + tool_bindings.definition_json, tool_bindings.revision AS binding_revision, \ + source_credentials.schema_version AS credential_schema_version, \ + source_credentials.payload_ciphertext AS credential_ciphertext, \ + source_credentials.revision AS credential_revision \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + JOIN tool_bindings ON tool_bindings.tool_id = tools.id \ + LEFT JOIN source_credentials ON source_credentials.source_id = sources.id \ + WHERE tools.id = ? AND tools.source_id = ? AND tools.present = 1 \ + AND sources.revision = ? AND sources.catalog_revision = ? \ + AND tools.revision = ? AND tool_bindings.revision = ? \ + AND source_credentials.revision IS ?"; + const REQUEST_LOG_SELECT_BY_ID: &str = "SELECT request_id, actor_api_token_id, surface, source_id, tool_id, path_snapshot, \ outcome, error_code, duration_ms, approval_id, created_at \ FROM request_logs WHERE request_id = ?"; -fn prepare_snapshot(snapshot: CatalogSnapshot) -> Result { - prepare_snapshot_with_limits(snapshot, PayloadLimits::default()) +fn prepare_snapshot_and_bindings( + snapshot: CatalogSnapshot, + bindings: Vec, +) -> Result<(PreparedSnapshot, PreparedToolBindings), CatalogError> { + prepare_snapshot_and_bindings_with_limits(snapshot, bindings, PayloadLimits::default()) +} + +fn prepare_initial_snapshot_and_bindings( + snapshot: InitialCatalogSnapshot, + bindings: Vec, +) -> Result<(PreparedSnapshot, PreparedToolBindings), CatalogError> { + let prepared = prepare_catalog_content_with_limits( + snapshot.artifacts, + snapshot.tools, + PayloadLimits::default(), + )?; + let prepared_bindings = prepare_tool_bindings_with_limits( + bindings, + PayloadLimits::default(), + prepared.payload_bytes, + )?; + Ok((prepared, prepared_bindings)) +} + +fn prepare_snapshot_and_bindings_with_limits( + snapshot: CatalogSnapshot, + bindings: Vec, + limits: PayloadLimits, +) -> Result<(PreparedSnapshot, PreparedToolBindings), CatalogError> { + let prepared = prepare_snapshot_with_limits(snapshot, limits)?; + let prepared_bindings = + prepare_tool_bindings_with_limits(bindings, limits, prepared.payload_bytes)?; + Ok((prepared, prepared_bindings)) +} + +#[cfg(test)] +fn prepare_tool_bindings( + bindings: Vec, +) -> Result { + prepare_tool_bindings_with_limits(bindings, PayloadLimits::default(), 0) +} + +fn prepare_tool_bindings_with_limits( + bindings: Vec, + limits: PayloadLimits, + initial_payload_bytes: usize, +) -> Result { + let mut prepared = Vec::with_capacity(bindings.len()); + let mut seen = HashSet::with_capacity(bindings.len()); + let mut total_bytes = initial_payload_bytes; + for binding in bindings { + let stable_key = validate_stable_text( + "invalid_stable_key", + "Tool stable keys must contain between 1 and 1024 characters.", + &binding.stable_key, + 1024, + )?; + if !seen.insert(stable_key.clone()) { + return Err(validation( + "duplicate_tool_binding", + "A staged catalog contains the same tool binding more than once.", + )); + } + let definition_json = match &binding.binding { + ToolBinding::OpenapiV1(binding) => { + if binding.version != 1 { + return Err(validation( + "invalid_tool_binding", + "The OpenAPI tool binding version is not supported.", + )); + } + serde_json::to_string(binding)? + } + }; + if definition_json.len() > limits.schema { + return Err(validation( + "tool_binding_too_large", + "A tool binding exceeds the serialized-size limit.", + )); + } + total_bytes = total_bytes + .checked_add(definition_json.len()) + .filter(|size| *size <= limits.aggregate) + .ok_or_else(|| { + validation( + "catalog_payload_too_large", + "The staged tool bindings exceed the aggregate size limit.", + ) + })?; + prepared.push(PreparedToolBinding { + stable_key, + protocol: binding.binding.protocol(), + version: binding.binding.version(), + definition_json, + }); + } + Ok(prepared) } fn prepare_snapshot_with_limits( snapshot: CatalogSnapshot, limits: PayloadLimits, ) -> Result { - if snapshot.tools.len() > MAX_ACTIVE_TOOLS_PER_SOURCE { + prepare_catalog_content_with_limits(snapshot.artifacts, snapshot.tools, limits) +} + +fn prepare_catalog_content_with_limits( + staged_artifacts: Vec, + staged_tools: Vec, + limits: PayloadLimits, +) -> Result { + if staged_tools.len() > MAX_ACTIVE_TOOLS_PER_SOURCE { return Err(validation( "catalog_too_large", format!( @@ -1709,11 +2157,11 @@ fn prepare_snapshot_with_limits( ), )); } - validate_artifact_count(snapshot.artifacts.len(), limits.artifact_count)?; + validate_artifact_count(staged_artifacts.len(), limits.artifact_count)?; let mut budget = PayloadBudget::new(limits); - let mut artifact_keys = HashSet::with_capacity(snapshot.artifacts.len()); - let mut artifacts = Vec::with_capacity(snapshot.artifacts.len()); - for artifact in snapshot.artifacts { + let mut artifact_keys = HashSet::with_capacity(staged_artifacts.len()); + let mut artifacts = Vec::with_capacity(staged_artifacts.len()); + for artifact in staged_artifacts { let stable_key = validate_stable_text( "invalid_artifact_key", "Artifact stable keys must contain between 1 and 512 characters.", @@ -1753,9 +2201,9 @@ fn prepare_snapshot_with_limits( .then_with(|| left.stable_key.cmp(&right.stable_key)) }); - let mut stable_keys = HashSet::with_capacity(snapshot.tools.len()); - let mut tools = Vec::with_capacity(snapshot.tools.len()); - for tool in snapshot.tools { + let mut stable_keys = HashSet::with_capacity(staged_tools.len()); + let mut tools = Vec::with_capacity(staged_tools.len()); + for tool in staged_tools { let stable_key = validate_stable_text( "invalid_stable_key", "Tool stable keys must contain between 1 and 1024 characters.", @@ -1868,6 +2316,12 @@ fn prepare_snapshot_with_limits( "schema_too_large", "A staged tool schema exceeds the serialized-size limit.", )?; + super::schema::compile(&tool.input_schema).map_err(|()| { + validation( + "invalid_tool_input_schema", + "A staged tool input schema is invalid or unsafe.", + ) + })?; let output_schema_json = tool .output_schema .as_ref() @@ -1904,7 +2358,11 @@ fn prepare_snapshot_with_limits( }); } tools.sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); - Ok(PreparedSnapshot { tools, artifacts }) + Ok(PreparedSnapshot { + tools, + artifacts, + payload_bytes: budget.consumed, + }) } fn parse_optional_mode(value: Option) -> Result, CatalogError> { @@ -2190,6 +2648,324 @@ fn normalize_sandbox_path(path: &str) -> String { path.strip_prefix("tools.").unwrap_or(path).to_owned() } +#[allow(clippy::too_many_arguments)] +async fn finalize_catalog_apply( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + audit: AuditContext<'_>, + source_id: &str, + source_path: &str, + kind: CatalogApplyKind<'_>, + active_tool_count: usize, + artifact_count: usize, + tombstoned_tool_count: usize, + now: i64, +) -> Result<(i64, i64, i64), CatalogError> { + let revisions = match kind { + CatalogApplyKind::Initial { .. } => { + sqlx::query_as::<_, (i64, i64)>( + "SELECT revision, catalog_revision FROM sources WHERE id = ?", + ) + .bind(source_id) + .fetch_one(&mut **transaction) + .await? + } + CatalogApplyKind::Refresh => { + sqlx::query_as::<_, (i64, i64)>( + "UPDATE sources SET revision = revision + 1, \ + catalog_revision = catalog_revision + 1, health_status = 'healthy', \ + health_error_code = NULL, updated_at = ?, last_refreshed_at = ? \ + WHERE id = ? RETURNING revision, catalog_revision", + ) + .bind(now) + .bind(now) + .bind(source_id) + .fetch_one(&mut **transaction) + .await? + } + }; + let global_revision = bump_global_revision(transaction, now).await?; + if let CatalogApplyKind::Initial { + source_kind, + slug, + credential_schema_version, + } = kind + { + insert_audit( + transaction, + audit, + "source.created", + Some(source_id), + None, + Some(source_path), + json!({ "kind": source_kind, "slug": slug }), + now, + ) + .await?; + insert_audit( + transaction, + audit, + "source.credential_changed", + Some(source_id), + None, + Some(source_path), + json!({ "schemaVersion": credential_schema_version }), + now, + ) + .await?; + } + insert_audit( + transaction, + audit, + "source.catalog_refreshed", + Some(source_id), + None, + Some(source_path), + json!({ + "catalogRevision": revisions.1, + "activeToolCount": active_tool_count, + "artifactCount": artifact_count, + "tombstonedToolCount": tombstoned_tool_count, + "globalRevision": global_revision + }), + now, + ) + .await?; + Ok((revisions.0, revisions.1, global_revision)) +} + +async fn apply_artifacts( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + artifacts: &[PreparedArtifact], + now: i64, +) -> Result<(), CatalogError> { + let staged_keys = artifacts + .iter() + .map(|artifact| { + ( + artifact.kind.as_str().to_owned(), + artifact.stable_key.clone(), + ) + }) + .collect::>(); + let existing_keys = sqlx::query_as::<_, (String, String)>( + "SELECT artifact_kind, stable_key FROM source_artifacts WHERE source_id = ?", + ) + .bind(source_id) + .fetch_all(&mut **transaction) + .await?; + for artifact in artifacts { + sqlx::query( + "INSERT INTO source_artifacts \ + (id, source_id, artifact_kind, stable_key, content_json, revision, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, 0, ?, ?) \ + ON CONFLICT(source_id, artifact_kind, stable_key) DO UPDATE SET \ + content_json = excluded.content_json, revision = source_artifacts.revision + 1, \ + updated_at = excluded.updated_at", + ) + .bind(Uuid::new_v4().to_string()) + .bind(source_id) + .bind(artifact.kind.as_str()) + .bind(&artifact.stable_key) + .bind(&artifact.content_json) + .bind(now) + .bind(now) + .execute(&mut **transaction) + .await?; + } + for (kind, stable_key) in existing_keys { + if !staged_keys.contains(&(kind.clone(), stable_key.clone())) { + sqlx::query( + "DELETE FROM source_artifacts \ + WHERE source_id = ? AND artifact_kind = ? AND stable_key = ?", + ) + .bind(source_id) + .bind(kind) + .bind(stable_key) + .execute(&mut **transaction) + .await?; + } + } + Ok(()) +} + +async fn apply_tools( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + tools: &[PreparedTool], + now: i64, +) -> Result, CatalogError> { + let existing = sqlx::query_as::<_, (String, String, i64)>( + "SELECT stable_key, local_name, present FROM tools WHERE source_id = ?", + ) + .bind(source_id) + .fetch_all(&mut **transaction) + .await?; + let existing_by_key = existing + .into_iter() + .map(|(stable_key, local_name, present)| (stable_key, (local_name, present != 0))) + .collect::>(); + let staged_keys = tools + .iter() + .map(|tool| tool.stable_key.clone()) + .collect::>(); + validate_tool_history(&existing_by_key, &staged_keys)?; + let used_names = existing_by_key + .values() + .map(|(local_name, _)| local_name.clone()) + .collect::>(); + let mut name_allocator = NameAllocator::new(used_names); + for tool in tools { + let local_name = existing_by_key + .get(&tool.stable_key) + .map(|(local_name, _)| local_name.clone()) + .unwrap_or_else(|| { + let base = normalize_tool_name(&tool.preferred_name); + name_allocator.allocate(&base, 128, '_') + }); + sqlx::query( + "INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, search_description, \ + search_short_grams, input_schema_json, output_schema_json, input_typescript, \ + output_typescript, typescript_definitions_json, intrinsic_mode, present, revision, \ + created_at, updated_at, last_seen_at, tombstoned_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, NULL) \ + ON CONFLICT(source_id, stable_key) DO UPDATE SET \ + display_name = excluded.display_name, description = excluded.description, \ + search_description = excluded.search_description, \ + search_short_grams = excluded.search_short_grams, \ + input_schema_json = excluded.input_schema_json, \ + output_schema_json = excluded.output_schema_json, \ + input_typescript = excluded.input_typescript, \ + output_typescript = excluded.output_typescript, \ + typescript_definitions_json = excluded.typescript_definitions_json, \ + intrinsic_mode = excluded.intrinsic_mode, present = 1, \ + revision = tools.revision + 1, updated_at = excluded.updated_at, \ + last_seen_at = excluded.last_seen_at, tombstoned_at = NULL", + ) + .bind(Uuid::new_v4().to_string()) + .bind(source_id) + .bind(&tool.stable_key) + .bind(local_name) + .bind(&tool.display_name) + .bind(&tool.description) + .bind(&tool.search_description) + .bind(&tool.search_short_grams) + .bind(&tool.input_schema_json) + .bind(&tool.output_schema_json) + .bind(&tool.input_typescript) + .bind(&tool.output_typescript) + .bind(&tool.typescript_definitions_json) + .bind(tool.intrinsic_mode.as_str()) + .bind(now) + .bind(now) + .bind(now) + .execute(&mut **transaction) + .await?; + } + let missing = existing_by_key + .iter() + .filter(|(stable_key, (_, present))| *present && !staged_keys.contains(*stable_key)) + .map(|(stable_key, _)| stable_key.clone()) + .collect::>(); + for stable_key in &missing { + sqlx::query( + "UPDATE tools SET present = 0, revision = revision + 1, updated_at = ?, \ + tombstoned_at = ? WHERE source_id = ? AND stable_key = ? AND present = 1", + ) + .bind(now) + .bind(now) + .bind(source_id) + .bind(stable_key) + .execute(&mut **transaction) + .await?; + } + Ok(missing) +} + +async fn apply_tool_bindings( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + bindings: &[PreparedToolBinding], + now: i64, +) -> Result<(), CatalogError> { + for binding in bindings { + let changed = sqlx::query( + "INSERT INTO tool_bindings \ + (tool_id, protocol, binding_version, definition_json, revision, created_at, updated_at) \ + SELECT id, ?, ?, ?, 0, ?, ? FROM tools \ + WHERE source_id = ? AND stable_key = ? AND present = 1 \ + ON CONFLICT(tool_id) DO UPDATE SET protocol = excluded.protocol, \ + binding_version = excluded.binding_version, \ + definition_json = excluded.definition_json, revision = tool_bindings.revision + 1, \ + updated_at = excluded.updated_at", + ) + .bind(binding.protocol) + .bind(binding.version) + .bind(&binding.definition_json) + .bind(now) + .bind(now) + .bind(source_id) + .bind(&binding.stable_key) + .execute(&mut **transaction) + .await? + .rows_affected(); + if changed != 1 { + return Err(validation( + "invalid_tool_binding", + "A tool binding does not match an active imported tool.", + )); + } + } + sqlx::query( + "DELETE FROM tool_bindings WHERE tool_id IN (\ + SELECT id FROM tools WHERE source_id = ? AND present = 0)", + ) + .bind(source_id) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn rebuild_search_indexes( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, +) -> Result<(), CatalogError> { + for table in ["tool_search", "tool_search_trigram", "tool_search_short"] { + let statement = format!("DELETE FROM {table} WHERE source_id = ?"); + sqlx::query(&statement) + .bind(source_id) + .execute(&mut **transaction) + .await?; + } + for table in ["tool_search", "tool_search_trigram"] { + let statement = format!( + "INSERT INTO {table} \ + (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ + SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ + replace(tools.local_name, '_', ' '), tools.search_description, \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.present = 1" + ); + sqlx::query(&statement) + .bind(source_id) + .execute(&mut **transaction) + .await?; + } + sqlx::query( + "INSERT INTO tool_search_short (source_id, tool_id, grams) \ + SELECT tools.source_id, tools.id, \ + sources.search_short_grams || ' ' || tools.search_short_grams \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.present = 1", + ) + .bind(source_id) + .execute(&mut **transaction) + .await?; + Ok(()) +} + async fn ensure_source_exists( transaction: &mut Transaction<'_, sqlx::Sqlite>, source_id: &str, @@ -2350,6 +3126,19 @@ async fn insert_audit_with_limit( Ok(()) } +#[cfg(test)] +async fn source_kind( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, +) -> Result { + let kind = sqlx::query_scalar::<_, String>("SELECT kind FROM sources WHERE id = ?") + .bind(source_id) + .fetch_optional(&mut **transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })?; + SourceKind::from_str(&kind) +} + fn validate_log_text(label: &str, value: &str, maximum_length: usize) -> Result<(), CatalogError> { if value.is_empty() || value.len() > maximum_length || value.contains('\0') { Err(validation( @@ -2402,11 +3191,14 @@ mod tests { use super::{ CatalogSnapshot, NameAllocator, PayloadLimits, insert_audit_with_limit, - prepare_snapshot_with_limits, + prepare_snapshot_and_bindings_with_limits, prepare_snapshot_with_limits, }; use crate::catalog::{ - ArtifactKind, AuditContext, CatalogError, StagedArtifact, StagedTool, ToolMode, + ArtifactKind, AuditContext, CatalogError, CreateSource, SourceKind, StagedArtifact, + StagedTool, StagedToolBinding, ToolBinding, ToolMode, }; + use crate::openapi::OpenApiBinding; + use crate::{AppConfig, ExecutorApp}; fn empty_snapshot() -> CatalogSnapshot { CatalogSnapshot { @@ -2562,6 +3354,83 @@ mod tests { )); } + #[test] + fn snapshot_and_bindings_share_one_aggregate_payload_budget() { + let snapshot = CatalogSnapshot { + expected_source_revision: 0, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged_tool()], + }; + let bindings = vec![StagedToolBinding { + stable_key: "tool".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: "/tool".to_owned(), + server_url: format!("https://{}.example.test", "x".repeat(100)), + parameters: Vec::new(), + request_body: None, + security: Vec::new(), + }), + }]; + let mut limits = small_limits(); + limits.schema = 256; + limits.aggregate = 1_024; + let snapshot_bytes = prepare_snapshot_with_limits(snapshot.clone(), limits) + .expect("snapshot should fit independently") + .payload_bytes; + let binding_bytes = match &bindings[0].binding { + ToolBinding::OpenapiV1(binding) => serde_json::to_string(binding) + .expect("binding should serialize") + .len(), + }; + limits.aggregate = snapshot_bytes + binding_bytes - 1; + assert!(prepare_snapshot_with_limits(snapshot.clone(), limits).is_ok()); + assert!(super::prepare_tool_bindings_with_limits(bindings.clone(), limits, 0).is_ok()); + assert!(matches!( + prepare_snapshot_and_bindings_with_limits(snapshot, bindings, limits), + Err(CatalogError::Validation { + code: "catalog_payload_too_large", + .. + }) + )); + } + + #[tokio::test] + async fn binding_replacement_rejects_non_openapi_sources() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let source = app + .catalog() + .create_source( + CreateSource { + kind: SourceKind::Graphql, + preferred_slug: "graphql".to_owned(), + display_name: "GraphQL".to_owned(), + description: None, + configuration: serde_json::Map::new(), + }, + AuditContext::system(None), + ) + .await + .expect("source should create"); + let error = app + .catalog() + .replace_tool_bindings(&source.id, Vec::new()) + .await + .expect_err("non-OpenAPI source should reject binding replacement"); + assert!(matches!( + error, + CatalogError::Validation { + code: "invalid_source_kind", + .. + } + )); + } + #[test] fn snapshot_payload_budget_charges_typescript_and_text_copies() { let mut typescript_snapshot = empty_snapshot(); diff --git a/src/lib.rs b/src/lib.rs index c33558a30..e8ef1be55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,8 @@ mod api; pub mod catalog; pub mod crypto; mod database; +pub mod openapi; +pub mod outbound; pub mod web_assets; pub use database::DatabaseError; diff --git a/src/openapi.rs b/src/openapi.rs new file mode 100644 index 000000000..34cfe4439 --- /dev/null +++ b/src/openapi.rs @@ -0,0 +1,2637 @@ +use std::{collections::BTreeMap, fmt}; + +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use url::Url; + +use crate::catalog::ToolMode; + +const MAX_REFERENCE_DEPTH: usize = 64; +const MAX_RESOLVED_NODES: usize = 250_000; +const MAX_RESOLVED_BYTES: usize = 16 * 1024 * 1024; +const MAX_COMPILED_OPERATIONS: usize = 100_000; +const MAX_PARAMETERS_PER_OPERATION: usize = 1_024; +const MAX_SECURITY_ALTERNATIVES: usize = 256; +const MAX_SECURITY_REQUIREMENTS_PER_ALTERNATIVE: usize = 64; +const HTTP_METHODS: [&str; 8] = [ + "get", "put", "post", "delete", "options", "head", "patch", "trace", +]; + +#[derive(Clone, Copy)] +enum OpenApiSchemaDialect { + OpenApi30, + JsonSchema202012, +} + +const OAS31_BASE_DIALECT: &str = "https://spec.openapis.org/oas/3.1/dialect/base"; +const JSON_SCHEMA_2020_12_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; + +#[derive(Clone, Debug)] +pub struct CompiledOpenApi { + pub document: Value, + pub title: String, + pub description: Option, + pub tools: Vec, +} + +#[derive(Clone, Debug)] +pub struct CompiledOpenApiTool { + pub stable_key: String, + pub preferred_name: String, + pub display_name: String, + pub description: Option, + pub input_schema: Value, + pub output_schema: Option, + pub intrinsic_mode: ToolMode, + pub binding: OpenApiBinding, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiBinding { + pub version: u32, + pub method: String, + pub path_template: String, + pub server_url: String, + pub parameters: Vec, + pub request_body: Option, + pub security: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiParameterBinding { + pub name: String, + pub location: OpenApiParameterLocation, + pub required: bool, + pub style: String, + pub explode: bool, + pub allow_reserved: bool, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OpenApiParameterLocation { + Path, + Query, + Header, + Cookie, +} + +impl OpenApiParameterLocation { + fn input_key(self) -> &'static str { + match self { + Self::Path => "path", + Self::Query => "query", + Self::Header => "headers", + Self::Cookie => "cookies", + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiRequestBodyBinding { + pub required: bool, + pub default_media_type: String, + pub media_types: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiSecurityAlternative { + pub requirements: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiSecurityRequirement { + pub scheme_name: String, + pub scopes: Vec, + pub scheme: OpenApiSecurityScheme, + pub oauth_flows: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum OpenApiSecurityScheme { + ApiKey { + name: String, + location: OpenApiParameterLocation, + }, + Http { + scheme: String, + bearer_format: Option, + }, + OAuth2, + OpenIdConnect { + url: String, + }, + MutualTls, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiOAuthFlows { + pub implicit: Option, + pub password: Option, + pub client_credentials: Option, + pub authorization_code: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiOAuthFlow { + pub authorization_url: Option, + pub token_url: Option, + pub refresh_url: Option, + pub scopes: BTreeMap, +} + +#[derive(Debug, Error)] +pub enum OpenApiError { + #[error("the OpenAPI document is not valid JSON or YAML")] + Parse, + #[error("only OpenAPI 3.0 and 3.1 documents are supported")] + UnsupportedVersion, + #[error("the OpenAPI document is invalid: {0}")] + InvalidDocument(&'static str), + #[error("external references are not supported: {0}")] + ExternalReference(String), + #[error("a local reference does not exist: {0}")] + ReferenceNotFound(String), + #[error("a local reference cycle was found: {0}")] + ReferenceCycle(String), + #[error("local reference resolution exceeded the depth limit")] + ReferenceDepth, + #[error("the OpenAPI document exceeds compiler limit {code}")] + LimitExceeded { code: &'static str }, + #[error("operation {method} {path} is invalid: {message}")] + InvalidOperation { + method: String, + path: String, + message: &'static str, + }, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiCredentialSet { + #[serde(default)] + pub schemes: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum OpenApiCredential { + ApiKey { + value: String, + }, + Bearer { + token: String, + }, + Basic { + username: String, + password: String, + }, + #[serde(rename = "oauth_access_token")] + OAuthAccessToken { + #[serde(rename = "access_token", alias = "accessToken")] + access_token: String, + }, +} + +pub type StaticCredentialSet = OpenApiCredentialSet; +pub type StaticCredentialScheme = OpenApiCredential; + +impl OpenApiCredentialSet { + pub fn validate(&self) -> Result<(), OpenApiCredentialError> { + if self.schemes.len() > 64 { + return Err(OpenApiCredentialError::TooManySchemes); + } + for (name, credential) in &self.schemes { + if name.is_empty() || name.len() > 256 { + return Err(OpenApiCredentialError::InvalidSchemeName); + } + credential.validate()?; + } + Ok(()) + } +} + +impl OpenApiCredential { + pub const fn credential_type(&self) -> &'static str { + match self { + Self::ApiKey { .. } => "api_key", + Self::Bearer { .. } => "bearer", + Self::Basic { .. } => "basic", + Self::OAuthAccessToken { .. } => "manual_oauth_access_token", + } + } + + fn validate(&self) -> Result<(), OpenApiCredentialError> { + let valid = match self { + Self::ApiKey { value } => !value.is_empty() && value.len() <= 16_384, + Self::Bearer { token } => !token.is_empty() && token.len() <= 16_384, + Self::Basic { username, password } => { + username.len() <= 4_096 && password.len() <= 16_384 + } + Self::OAuthAccessToken { access_token } => { + !access_token.is_empty() && access_token.len() <= 16_384 + } + }; + if valid { + Ok(()) + } else { + Err(OpenApiCredentialError::InvalidValue) + } + } +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum OpenApiCredentialError { + #[error("at most 64 OpenAPI security schemes may be configured")] + TooManySchemes, + #[error("an OpenAPI security scheme name is invalid")] + InvalidSchemeName, + #[error("an OpenAPI credential value is invalid")] + InvalidValue, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OpenApiProtocolRequest { + pub method: String, + pub url: Url, + pub headers: BTreeMap, + pub body: Vec, +} + +type CookieParameters = BTreeMap>; + +#[derive(Debug, Error)] +pub enum OpenApiInvocationError { + #[error("tool arguments must be an object")] + InvalidArguments, + #[error("a required tool argument is missing: {0}")] + MissingArgument(String), + #[error("the tool arguments contain an invalid value: {0}")] + InvalidArgument(String), + #[error("no configured credential satisfies the operation security requirements")] + UnsatisfiedSecurity, + #[error("the configured credential does not match security scheme {0}")] + InvalidCredential(String), + #[error("the configured OpenAPI credentials are invalid")] + InvalidCredentialConfiguration, + #[error("an unsafe HTTP header was rejected: {0}")] + InvalidHeader(String), + #[error("the compiled operation URL is invalid")] + InvalidUrl, +} + +pub fn build_protocol_request( + binding: &OpenApiBinding, + arguments: &Value, + credentials: &OpenApiCredentialSet, +) -> Result { + build_protocol_request_with_base(binding, arguments, credentials, None) +} + +pub fn build_protocol_request_with_base( + binding: &OpenApiBinding, + arguments: &Value, + credentials: &OpenApiCredentialSet, + document_base_url: Option<&Url>, +) -> Result { + credentials + .validate() + .map_err(|_| OpenApiInvocationError::InvalidCredentialConfiguration)?; + let method = validate_method(&binding.method)?; + let arguments = arguments + .as_object() + .ok_or(OpenApiInvocationError::InvalidArguments)?; + validate_argument_members(binding, arguments)?; + let mut path = binding.path_template.clone(); + let mut query = Vec::new(); + let mut headers = BTreeMap::new(); + let mut cookies = BTreeMap::new(); + + for parameter in &binding.parameters { + validate_parameter_serialization(parameter)?; + let carrier = arguments + .get(parameter.location.input_key()) + .and_then(Value::as_object); + let value = carrier.and_then(|carrier| carrier.get(¶meter.name)); + let Some(value) = value else { + if parameter.required { + return Err(OpenApiInvocationError::MissingArgument(format!( + "{}.{}", + parameter.location.input_key(), + parameter.name + ))); + } + continue; + }; + match parameter.location { + OpenApiParameterLocation::Path => { + let serialized = serialize_parameter(value, parameter, false)?; + let encoded = encode_path_value(&serialized); + let marker = format!("{{{}}}", parameter.name); + if !path.contains(&marker) { + return Err(OpenApiInvocationError::InvalidArgument(format!( + "path.{}", + parameter.name + ))); + } + path = path.replace(&marker, &encoded); + } + OpenApiParameterLocation::Query => { + append_query_parameter(&mut query, value, parameter)?; + } + OpenApiParameterLocation::Header => { + validate_user_header_name(¶meter.name)?; + let serialized = serialize_parameter(value, parameter, false)?; + validate_header_value(¶meter.name, &serialized)?; + headers.insert(parameter.name.to_ascii_lowercase(), serialized); + } + OpenApiParameterLocation::Cookie => { + validate_cookie_name(¶meter.name)?; + append_cookie_parameter(&mut cookies, value, parameter)?; + } + } + } + if path.contains('{') || path.contains('}') { + return Err(OpenApiInvocationError::InvalidArgument("path".to_owned())); + } + + let mut first_security_error = None; + let mut selected = None; + for alternative in &binding.security { + if security_alternative_has_carrier_conflict(alternative) + || !alternative.requirements.iter().all(|requirement| { + credentials + .schemes + .get(&requirement.scheme_name) + .is_some_and(|credential| credential_matches(&requirement.scheme, credential)) + }) + { + continue; + } + let mut candidate_query = query.clone(); + let mut candidate_headers = headers.clone(); + let mut candidate_cookies = cookies.clone(); + let applied = alternative.requirements.iter().try_for_each(|requirement| { + let credential = credentials + .schemes + .get(&requirement.scheme_name) + .ok_or(OpenApiInvocationError::UnsatisfiedSecurity)?; + apply_credential( + requirement, + credential, + &mut candidate_query, + &mut candidate_headers, + &mut candidate_cookies, + ) + }); + match applied { + Ok(()) => { + selected = Some((candidate_query, candidate_headers, candidate_cookies)); + break; + } + Err(error) => { + first_security_error.get_or_insert(error); + } + }; + } + let (selected_query, selected_headers, selected_cookies) = selected.ok_or_else(|| { + first_security_error.unwrap_or(OpenApiInvocationError::UnsatisfiedSecurity) + })?; + query = selected_query; + headers = selected_headers; + cookies = selected_cookies; + + if !cookies.is_empty() { + let cookie = cookies + .into_values() + .filter_map(|pairs| { + (!pairs.is_empty()).then(|| { + pairs + .into_iter() + .map(|(name, value)| format!("{name}={}", encode_cookie_value(&value))) + .collect::>() + .join("&") + }) + }) + .collect::>() + .join("; "); + headers.insert("cookie".to_owned(), cookie); + } + let mut url = operation_url(&binding.server_url, &path, document_base_url)?; + if !query.is_empty() { + let mut pairs = url.query_pairs_mut(); + for (name, value) in query { + pairs.append_pair(&name, &value); + } + } + + let body = if let Some(body_binding) = &binding.request_body { + let body = arguments.get("body").cloned(); + if body_binding.required && body.is_none() { + return Err(OpenApiInvocationError::MissingArgument("body".to_owned())); + } + let requested = match arguments.get("contentType") { + Some(Value::String(requested)) => requested.as_str(), + Some(_) => { + return Err(OpenApiInvocationError::InvalidArgument( + "contentType".to_owned(), + )); + } + None => &body_binding.default_media_type, + }; + if !body_binding + .media_types + .iter() + .any(|media| media == requested) + { + return Err(OpenApiInvocationError::InvalidArgument( + "contentType".to_owned(), + )); + } + if let Some(body) = &body { + validate_request_body_value(requested, body)?; + } + if body.is_some() { + headers.insert("content-type".to_owned(), requested.to_owned()); + } else if arguments.contains_key("contentType") { + return Err(OpenApiInvocationError::InvalidArgument( + "contentType".to_owned(), + )); + } + body.map_or_else( + || Ok(Vec::new()), + |body| encode_request_body(requested, &body), + )? + } else { + if arguments.contains_key("body") || arguments.contains_key("contentType") { + return Err(OpenApiInvocationError::InvalidArgument("body".to_owned())); + } + Vec::new() + }; + Ok(OpenApiProtocolRequest { + method, + url, + headers, + body, + }) +} + +fn validate_parameter_serialization( + parameter: &OpenApiParameterBinding, +) -> Result<(), OpenApiInvocationError> { + let supported = !parameter.allow_reserved + && match parameter.location { + OpenApiParameterLocation::Query => matches!( + parameter.style.as_str(), + "form" | "spaceDelimited" | "pipeDelimited" | "deepObject" + ), + OpenApiParameterLocation::Path | OpenApiParameterLocation::Header => { + parameter.style == "simple" + } + OpenApiParameterLocation::Cookie => parameter.style == "form", + }; + if supported { + Ok(()) + } else { + Err(OpenApiInvocationError::InvalidArgument(format!( + "{}.{}", + parameter.location.input_key(), + parameter.name + ))) + } +} + +fn validate_method(method: &str) -> Result { + let method = method.to_ascii_uppercase(); + if matches!( + method.as_str(), + "GET" | "PUT" | "POST" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH" | "TRACE" + ) { + Ok(method) + } else { + Err(OpenApiInvocationError::InvalidArgument("method".to_owned())) + } +} + +fn encode_request_body(media_type: &str, body: &Value) -> Result, OpenApiInvocationError> { + let base = media_type + .split_once(';') + .map_or(media_type, |(base, _)| base) + .trim(); + match base { + "application/json" => serde_json::to_vec(body) + .map_err(|_| OpenApiInvocationError::InvalidArgument("body".to_owned())), + value if value.ends_with("+json") => serde_json::to_vec(body) + .map_err(|_| OpenApiInvocationError::InvalidArgument("body".to_owned())), + "text/plain" => Ok(body + .as_str() + .ok_or_else(|| OpenApiInvocationError::InvalidArgument("body".to_owned()))? + .as_bytes() + .to_vec()), + "application/x-www-form-urlencoded" => { + let object = body + .as_object() + .ok_or_else(|| OpenApiInvocationError::InvalidArgument("body".to_owned()))?; + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in object { + serializer.append_pair(name, &scalar(value, "body")?); + } + Ok(serializer.finish().into_bytes()) + } + _ => Err(OpenApiInvocationError::InvalidArgument("body".to_owned())), + } +} + +fn validate_request_body_value( + media_type: &str, + body: &Value, +) -> Result<(), OpenApiInvocationError> { + let base = media_type + .split_once(';') + .map_or(media_type, |(base, _)| base) + .trim(); + if base == "text/plain" && !body.is_string() { + return Err(OpenApiInvocationError::InvalidArgument("body".to_owned())); + } + if base == "application/x-www-form-urlencoded" { + let object = body + .as_object() + .ok_or_else(|| OpenApiInvocationError::InvalidArgument("body".to_owned()))?; + if object + .values() + .any(|value| !matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_))) + { + return Err(OpenApiInvocationError::InvalidArgument("body".to_owned())); + } + } + Ok(()) +} + +fn operation_url( + server_url: &str, + path: &str, + document_base_url: Option<&Url>, +) -> Result { + let server = Url::parse(server_url) + .or_else(|_| { + document_base_url + .ok_or(url::ParseError::RelativeUrlWithoutBase)? + .join(server_url) + }) + .map_err(|_| OpenApiInvocationError::InvalidUrl)?; + if !matches!(server.scheme(), "http" | "https") + || server.host().is_none() + || !server.username().is_empty() + || server.password().is_some() + || server.query().is_some() + || server.fragment().is_some() + { + return Err(OpenApiInvocationError::InvalidUrl); + } + let base_path = server.path().trim_end_matches('/'); + let combined = format!("{base_path}/{}", path.trim_start_matches('/')); + let mut url = server; + url.set_path(&combined); + Ok(url) +} + +fn validate_argument_members( + binding: &OpenApiBinding, + arguments: &Map, +) -> Result<(), OpenApiInvocationError> { + let mut allowed_root = std::collections::BTreeSet::new(); + let mut carrier_members = BTreeMap::<&str, std::collections::BTreeSet<&str>>::new(); + for parameter in &binding.parameters { + let carrier = parameter.location.input_key(); + allowed_root.insert(carrier); + carrier_members + .entry(carrier) + .or_default() + .insert(¶meter.name); + } + if binding.request_body.is_some() { + allowed_root.insert("body"); + allowed_root.insert("contentType"); + } + for (key, value) in arguments { + if !allowed_root.contains(key.as_str()) { + return Err(OpenApiInvocationError::InvalidArgument(key.clone())); + } + let Some(allowed_members) = carrier_members.get(key.as_str()) else { + continue; + }; + let members = value + .as_object() + .ok_or_else(|| OpenApiInvocationError::InvalidArgument(key.clone()))?; + if let Some(unknown) = members + .keys() + .find(|member| !allowed_members.contains(member.as_str())) + { + return Err(OpenApiInvocationError::InvalidArgument(format!( + "{key}.{unknown}" + ))); + } + } + Ok(()) +} + +fn credential_matches(scheme: &OpenApiSecurityScheme, credential: &OpenApiCredential) -> bool { + match (scheme, credential) { + (OpenApiSecurityScheme::ApiKey { .. }, OpenApiCredential::ApiKey { .. }) => true, + (OpenApiSecurityScheme::Http { scheme, .. }, OpenApiCredential::Basic { .. }) => { + scheme == "basic" + } + ( + OpenApiSecurityScheme::Http { scheme, .. }, + OpenApiCredential::Bearer { .. } | OpenApiCredential::OAuthAccessToken { .. }, + ) => scheme == "bearer", + ( + OpenApiSecurityScheme::OAuth2 | OpenApiSecurityScheme::OpenIdConnect { .. }, + OpenApiCredential::OAuthAccessToken { .. }, + ) => true, + _ => false, + } +} + +fn apply_credential( + requirement: &OpenApiSecurityRequirement, + credential: &OpenApiCredential, + query: &mut Vec<(String, String)>, + headers: &mut BTreeMap, + cookies: &mut CookieParameters, +) -> Result<(), OpenApiInvocationError> { + match (&requirement.scheme, credential) { + (OpenApiSecurityScheme::ApiKey { name, location }, OpenApiCredential::ApiKey { value }) => { + reject_control_characters(name, value)?; + match location { + OpenApiParameterLocation::Query => { + query.retain(|(existing, _)| existing != name); + query.push((name.clone(), value.clone())); + } + OpenApiParameterLocation::Header => { + validate_auth_header_name(name)?; + validate_header_value(name, value)?; + headers.insert(name.to_ascii_lowercase(), value.clone()); + } + OpenApiParameterLocation::Cookie => { + validate_cookie_name(name)?; + for pairs in cookies.values_mut() { + pairs.retain(|(existing, _)| existing != name); + } + cookies.retain(|_, pairs| !pairs.is_empty()); + cookies.insert(name.clone(), vec![(name.clone(), value.clone())]); + } + OpenApiParameterLocation::Path => { + return Err(OpenApiInvocationError::InvalidCredential( + requirement.scheme_name.clone(), + )); + } + } + } + ( + OpenApiSecurityScheme::Http { scheme, .. }, + OpenApiCredential::Basic { username, password }, + ) if scheme == "basic" => { + reject_control_characters("username", username)?; + reject_control_characters("password", password)?; + use base64::Engine as _; + let encoded = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + let value = format!("Basic {encoded}"); + validate_header_value("authorization", &value)?; + headers.insert("authorization".to_owned(), value); + } + (OpenApiSecurityScheme::Http { scheme, .. }, OpenApiCredential::Bearer { token }) + if scheme == "bearer" => + { + reject_control_characters("token", token)?; + let value = format!("Bearer {token}"); + validate_header_value("authorization", &value)?; + headers.insert("authorization".to_owned(), value); + } + ( + OpenApiSecurityScheme::Http { scheme, .. }, + OpenApiCredential::OAuthAccessToken { access_token }, + ) if scheme == "bearer" => { + reject_control_characters("access token", access_token)?; + let value = format!("Bearer {access_token}"); + validate_header_value("authorization", &value)?; + headers.insert("authorization".to_owned(), value); + } + ( + OpenApiSecurityScheme::OAuth2 | OpenApiSecurityScheme::OpenIdConnect { .. }, + OpenApiCredential::OAuthAccessToken { access_token }, + ) => { + reject_control_characters("access token", access_token)?; + let value = format!("Bearer {access_token}"); + validate_header_value("authorization", &value)?; + headers.insert("authorization".to_owned(), value); + } + _ => { + return Err(OpenApiInvocationError::InvalidCredential( + requirement.scheme_name.clone(), + )); + } + } + Ok(()) +} + +fn append_query_parameter( + output: &mut Vec<(String, String)>, + value: &Value, + parameter: &OpenApiParameterBinding, +) -> Result<(), OpenApiInvocationError> { + if parameter.style == "deepObject" { + let object = value.as_object().ok_or_else(|| { + OpenApiInvocationError::InvalidArgument(format!("query.{}", parameter.name)) + })?; + for (key, value) in object { + output.push(( + format!("{}[{key}]", parameter.name), + scalar(value, &format!("query.{}", parameter.name))?, + )); + } + return Ok(()); + } + if parameter.explode + && let Some(values) = value.as_array() + { + for value in values { + output.push(( + parameter.name.clone(), + scalar(value, &format!("query.{}", parameter.name))?, + )); + } + return Ok(()); + } + if parameter.explode + && let Some(object) = value.as_object() + { + for (key, value) in object { + output.push(( + key.clone(), + scalar(value, &format!("query.{}", parameter.name))?, + )); + } + return Ok(()); + } + output.push(( + parameter.name.clone(), + serialize_parameter(value, parameter, true)?, + )); + Ok(()) +} + +fn append_cookie_parameter( + output: &mut CookieParameters, + value: &Value, + parameter: &OpenApiParameterBinding, +) -> Result<(), OpenApiInvocationError> { + let label = format!("cookies.{}", parameter.name); + let pairs = if let Some(values) = value.as_array() { + if parameter.explode { + values + .iter() + .map(|value| Ok((parameter.name.clone(), scalar(value, &label)?))) + .collect::, OpenApiInvocationError>>()? + } else { + vec![( + parameter.name.clone(), + values + .iter() + .map(|value| scalar(value, &label)) + .collect::, _>>()? + .join(","), + )] + } + } else if let Some(object) = value.as_object() { + if parameter.explode { + object + .iter() + .map(|(name, value)| { + validate_cookie_name(name)?; + Ok((name.clone(), scalar(value, &label)?)) + }) + .collect::, OpenApiInvocationError>>()? + } else { + let mut flattened = Vec::with_capacity(object.len() * 2); + for (name, value) in object { + flattened.push(name.clone()); + flattened.push(scalar(value, &label)?); + } + vec![(parameter.name.clone(), flattened.join(","))] + } + } else { + vec![(parameter.name.clone(), scalar(value, &label)?)] + }; + for (name, value) in &pairs { + validate_cookie_name(name)?; + reject_control_characters(name, value)?; + } + output.insert(parameter.name.clone(), pairs); + Ok(()) +} + +fn serialize_parameter( + value: &Value, + parameter: &OpenApiParameterBinding, + query: bool, +) -> Result { + let label = format!("{}.{}", parameter.location.input_key(), parameter.name); + if let Some(values) = value.as_array() { + let delimiter = match parameter.style.as_str() { + "spaceDelimited" if query => " ", + "pipeDelimited" if query => "|", + _ => ",", + }; + return values + .iter() + .map(|value| scalar(value, &label)) + .collect::, _>>() + .map(|values| values.join(delimiter)); + } + if let Some(object) = value.as_object() { + let mut values = Vec::with_capacity(object.len() * 2); + for (key, value) in object { + let value = scalar(value, &label)?; + if parameter.explode { + values.push(format!("{key}={value}")); + } else { + values.push(key.clone()); + values.push(value); + } + } + return Ok(values.join(",")); + } + scalar(value, &label) +} + +fn scalar(value: &Value, label: &str) -> Result { + match value { + Value::String(value) => Ok(value.clone()), + Value::Number(value) => Ok(value.to_string()), + Value::Bool(value) => Ok(value.to_string()), + Value::Null => Ok(String::new()), + _ => Err(OpenApiInvocationError::InvalidArgument(label.to_owned())), + } +} + +fn validate_user_header_name(name: &str) -> Result<(), OpenApiInvocationError> { + let normalized = name.to_ascii_lowercase(); + if protected_header(&normalized, false) { + return Err(OpenApiInvocationError::InvalidHeader(name.to_owned())); + } + validate_header_token(name) +} + +fn validate_auth_header_name(name: &str) -> Result<(), OpenApiInvocationError> { + let normalized = name.to_ascii_lowercase(); + if protected_header(&normalized, true) { + return Err(OpenApiInvocationError::InvalidHeader(name.to_owned())); + } + validate_header_token(name) +} + +fn validate_header_token(name: &str) -> Result<(), OpenApiInvocationError> { + if is_http_token(name) { + Ok(()) + } else { + Err(OpenApiInvocationError::InvalidHeader(name.to_owned())) + } +} + +fn validate_cookie_name(name: &str) -> Result<(), OpenApiInvocationError> { + validate_header_token(name) +} + +fn protected_header(normalized: &str, allow_authorization: bool) -> bool { + (!allow_authorization && normalized == "authorization") + || normalized.starts_with("proxy-") + || normalized.starts_with("x-forwarded-") + || matches!( + normalized, + "host" + | "content-length" + | "transfer-encoding" + | "connection" + | "cookie" + | "proxy-connection" + | "upgrade" + | "te" + | "trailer" + | "forwarded" + | "via" + | "referer" + | "x-forwarded" + | "x-real-ip" + | "x-original-url" + | "x-rewrite-url" + | "x-original-host" + | "x-http-method-override" + | "x-http-method" + | "x-method-override" + | "x-original-method" + ) +} + +fn is_http_token(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +fn validate_header_value(name: &str, value: &str) -> Result<(), OpenApiInvocationError> { + if value + .bytes() + .any(|byte| byte != b'\t' && !(b' '..=b'~').contains(&byte)) + { + Err(OpenApiInvocationError::InvalidHeader(name.to_owned())) + } else { + Ok(()) + } +} + +fn reject_control_characters(name: &str, value: &str) -> Result<(), OpenApiInvocationError> { + if value.chars().any(char::is_control) { + Err(OpenApiInvocationError::InvalidArgument(name.to_owned())) + } else { + Ok(()) + } +} + +fn encode_path_value(value: &str) -> String { + percent_encode(value, false) +} + +fn encode_cookie_value(value: &str) -> String { + percent_encode(value, true) +} + +fn percent_encode(value: &str, cookie: bool) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + let safe = byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'.' | b'_' | b'~') + || (cookie && matches!(byte, b':' | b'@')); + if safe { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +pub fn compile_document(bytes: &[u8]) -> Result { + let document = parse_document(bytes)?; + compile_value(document) +} + +pub fn compile_value(document: Value) -> Result { + let document_bytes = serde_json::to_vec(&document) + .map_err(|_| OpenApiError::InvalidDocument("the document cannot be serialized"))? + .len(); + if document_bytes > MAX_RESOLVED_BYTES { + return Err(limit("document_bytes")); + } + let root = document + .as_object() + .ok_or(OpenApiError::InvalidDocument("the root must be an object"))?; + let schema_dialect = validate_version(root)?; + reject_external_references(&document)?; + if matches!(schema_dialect, OpenApiSchemaDialect::OpenApi30) { + reject_openapi30_reference_siblings(&document)?; + } + + let info = object_field(root, "info")?; + let title = string_field(info, "title")?.to_owned(); + let description = optional_string(info, "description")?.map(str::to_owned); + let paths = object_field(root, "paths")?; + let root_servers = root.get("servers"); + let root_security = root.get("security"); + let security_schemes = root + .get("components") + .and_then(Value::as_object) + .and_then(|components| components.get("securitySchemes")) + .and_then(Value::as_object); + let mut tools = Vec::new(); + let mut budget = CompileBudget { + resolved_nodes: 0, + resolved_bytes: document_bytes, + }; + if root_servers.is_some() { + select_server(root_servers, &document, "ROOT", "/", &mut budget)?; + } + + for (path, raw_path_item) in paths { + if !path.starts_with('/') { + continue; + } + let mut stack = Vec::new(); + let path_item = resolve_value(raw_path_item, &document, &mut stack, 0, &mut budget)?; + let path_item = path_item + .as_object() + .ok_or_else(|| invalid_operation("PATH", path, "the path item must be an object"))?; + if path_item.get("servers").is_some() { + select_server( + path_item.get("servers"), + &document, + "PATH", + path, + &mut budget, + )?; + } + let path_parameters = + parse_parameters(path_item.get("parameters"), &document, path, &mut budget)?; + + for method in HTTP_METHODS { + let Some(raw_operation) = path_item.get(method) else { + continue; + }; + if tools.len() >= MAX_COMPILED_OPERATIONS { + return Err(limit("too_many_operations")); + } + let mut stack = Vec::new(); + let operation = resolve_value(raw_operation, &document, &mut stack, 0, &mut budget)?; + let operation = operation.as_object().ok_or_else(|| { + invalid_operation(method, path, "the operation must be an object") + })?; + if operation.get("servers").is_some() { + select_server( + operation.get("servers"), + &document, + method, + path, + &mut budget, + )?; + } + let operation_parameters = + parse_parameters(operation.get("parameters"), &document, path, &mut budget)?; + let parameters = merge_parameters(path_parameters.clone(), operation_parameters); + if parameters.len() > MAX_PARAMETERS_PER_OPERATION { + return Err(limit("too_many_parameters")); + } + validate_path_parameters(method, path, ¶meters)?; + let server_url = select_server( + operation + .get("servers") + .or_else(|| path_item.get("servers")) + .or(root_servers), + &document, + method, + path, + &mut budget, + )?; + let request_body = parse_request_body( + operation.get("requestBody"), + &document, + method, + path, + &mut budget, + )?; + let security = parse_security( + operation.get("security").or(root_security), + security_schemes, + &document, + method, + path, + &mut budget, + )?; + let preferred_name = operation + .get("x-executor-toolPath") + .and_then(Value::as_str) + .or_else(|| operation.get("operationId").and_then(Value::as_str)) + .map(str::to_owned) + .unwrap_or_else(|| fallback_name(method, path)); + let display_name = operation + .get("summary") + .and_then(Value::as_str) + .or_else(|| operation.get("operationId").and_then(Value::as_str)) + .map(str::to_owned) + .unwrap_or_else(|| format!("{} {path}", method.to_ascii_uppercase())); + let description = operation + .get("description") + .and_then(Value::as_str) + .map(str::to_owned); + let input_schema = + build_input_schema(¶meters, request_body.as_ref(), schema_dialect)?; + let output_schema = parse_output_schema( + operation.get("responses"), + &document, + method, + path, + &mut budget, + )?; + let binding = OpenApiBinding { + version: 1, + method: method.to_ascii_uppercase(), + path_template: path.clone(), + server_url, + parameters: parameters + .iter() + .map(|parameter| parameter.binding.clone()) + .collect(), + request_body: request_body.as_ref().map(|body| body.binding.clone()), + security, + }; + budget.charge_serialized(&input_schema)?; + if let Some(output_schema) = &output_schema { + budget.charge_serialized(output_schema)?; + } + budget.charge_serialized(&binding)?; + tools.push(CompiledOpenApiTool { + stable_key: stable_key(method, path), + preferred_name, + display_name, + description, + input_schema, + output_schema, + intrinsic_mode: intrinsic_mode(method), + binding, + }); + } + } + tools.sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + Ok(CompiledOpenApi { + document, + title, + description, + tools, + }) +} + +fn parse_document(bytes: &[u8]) -> Result { + let mut json = serde_json::Deserializer::from_slice(bytes); + if let Ok(value) = UniqueValue::deserialize(&mut json) + && json.end().is_ok() + { + return Ok(value.0); + } + serde_yaml::from_slice::(bytes) + .map(|value| value.0) + .map_err(|_| OpenApiError::Parse) +} + +struct UniqueValue(Value); + +impl<'de> Deserialize<'de> for UniqueValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(UniqueValueVisitor) + } +} + +struct UniqueValueVisitor; + +impl<'de> Visitor<'de> for UniqueValueVisitor { + type Value = UniqueValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON-compatible value without duplicate object keys") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(UniqueValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + serde_json::Number::from_f64(value) + .map(Value::Number) + .map(UniqueValue) + .ok_or_else(|| E::custom("non-finite numbers are not supported")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(UniqueValue(Value::String(value.to_owned()))) + } + + fn visit_string(self, value: String) -> Result { + Ok(UniqueValue(Value::String(value))) + } + + fn visit_none(self) -> Result { + Ok(UniqueValue(Value::Null)) + } + + fn visit_unit(self) -> Result { + Ok(UniqueValue(Value::Null)) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element::()? { + values.push(value.0); + } + Ok(UniqueValue(Value::Array(values))) + } + + fn visit_map(self, mut entries: A) -> Result + where + A: MapAccess<'de>, + { + let mut object = Map::new(); + while let Some((key, value)) = entries.next_entry::()? { + if object.insert(key.clone(), value.0).is_some() { + return Err(de::Error::custom(format!("duplicate object key: {key}"))); + } + } + Ok(UniqueValue(Value::Object(object))) + } +} + +fn validate_version(root: &Map) -> Result { + if root.contains_key("swagger") { + return Err(OpenApiError::UnsupportedVersion); + } + let version = root + .get("openapi") + .and_then(Value::as_str) + .ok_or(OpenApiError::UnsupportedVersion)?; + if version.starts_with("3.0.") { + Ok(OpenApiSchemaDialect::OpenApi30) + } else if version.starts_with("3.1.") { + validate_openapi31_schema_dialect(root)?; + Ok(OpenApiSchemaDialect::JsonSchema202012) + } else { + Err(OpenApiError::UnsupportedVersion) + } +} + +fn validate_openapi31_schema_dialect(root: &Map) -> Result<(), OpenApiError> { + let Some(dialect) = root.get("jsonSchemaDialect") else { + return Ok(()); + }; + let dialect = dialect.as_str().ok_or(OpenApiError::InvalidDocument( + "jsonSchemaDialect must be a URI string", + ))?; + let dialect = dialect.strip_suffix('#').unwrap_or(dialect); + if matches!(dialect, OAS31_BASE_DIALECT | JSON_SCHEMA_2020_12_DIALECT) { + Ok(()) + } else { + Err(OpenApiError::InvalidDocument( + "the OpenAPI 3.1 JSON Schema dialect is not supported", + )) + } +} + +fn reject_external_references(value: &Value) -> Result<(), OpenApiError> { + match value { + Value::Array(values) => { + for value in values { + reject_external_references(value)?; + } + } + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) + && !reference.starts_with('#') + { + return Err(OpenApiError::ExternalReference(reference.to_owned())); + } + for value in object.values() { + reject_external_references(value)?; + } + } + _ => {} + } + Ok(()) +} + +fn reject_openapi30_reference_siblings(value: &Value) -> Result<(), OpenApiError> { + match value { + Value::Array(values) => { + for value in values { + reject_openapi30_reference_siblings(value)?; + } + } + Value::Object(object) => { + if object.contains_key("$ref") && object.len() > 1 { + return Err(OpenApiError::InvalidDocument( + "OpenAPI 3.0 reference objects cannot have sibling fields", + )); + } + for value in object.values() { + reject_openapi30_reference_siblings(value)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + Ok(()) +} + +#[derive(Default)] +struct CompileBudget { + resolved_nodes: usize, + resolved_bytes: usize, +} + +impl CompileBudget { + fn charge(&mut self, value: &Value) -> Result<(), OpenApiError> { + self.resolved_nodes = self + .resolved_nodes + .checked_add(1) + .ok_or_else(|| limit("resolved_nodes"))?; + if self.resolved_nodes > MAX_RESOLVED_NODES { + return Err(limit("resolved_nodes")); + } + let shallow_bytes = match value { + Value::Null | Value::Bool(_) => 8, + Value::Number(number) => number.to_string().len() + 8, + Value::String(value) => value.len() + 8, + Value::Array(values) => values.len().saturating_mul(2) + 8, + Value::Object(object) => object.keys().fold(8usize, |bytes, key| { + bytes.saturating_add(key.len()).saturating_add(4) + }), + }; + self.charge_bytes(shallow_bytes)?; + Ok(()) + } + + fn charge_serialized(&mut self, value: &T) -> Result<(), OpenApiError> { + let bytes = serde_json::to_vec(value) + .map_err(|_| OpenApiError::InvalidDocument("compiled data cannot be serialized"))? + .len(); + self.charge_bytes(bytes) + } + + fn charge_bytes(&mut self, bytes: usize) -> Result<(), OpenApiError> { + self.resolved_bytes = self + .resolved_bytes + .checked_add(bytes) + .ok_or_else(|| limit("resolved_bytes"))?; + if self.resolved_bytes > MAX_RESOLVED_BYTES { + return Err(limit("resolved_bytes")); + } + Ok(()) + } +} + +fn resolve_value( + value: &Value, + root: &Value, + stack: &mut Vec, + depth: usize, + budget: &mut CompileBudget, +) -> Result { + if depth > MAX_REFERENCE_DEPTH { + return Err(OpenApiError::ReferenceDepth); + } + budget.charge(value)?; + match value { + Value::Array(values) => values + .iter() + .map(|value| resolve_value(value, root, stack, depth + 1, budget)) + .collect::, _>>() + .map(Value::Array), + Value::Object(object) => { + if let Some(reference) = object.get("$ref") { + let reference = reference + .as_str() + .ok_or(OpenApiError::InvalidDocument("$ref must be a string"))?; + if !reference.starts_with('#') { + return Err(OpenApiError::ExternalReference(reference.to_owned())); + } + let pointer = local_pointer(reference)?; + if stack.iter().any(|entry| entry == reference) { + return Err(OpenApiError::ReferenceCycle(reference.to_owned())); + } + let target = root + .pointer(&pointer) + .ok_or_else(|| OpenApiError::ReferenceNotFound(reference.to_owned()))?; + stack.push(reference.to_owned()); + let resolved = resolve_value(target, root, stack, depth + 1, budget)?; + stack.pop(); + if object.len() == 1 { + return Ok(resolved); + } + let mut resolved = + resolved + .as_object() + .cloned() + .ok_or(OpenApiError::InvalidDocument( + "a referenced value with siblings must be an object", + ))?; + for (key, sibling) in object { + if key != "$ref" { + resolved.insert( + key.clone(), + resolve_value(sibling, root, stack, depth + 1, budget)?, + ); + } + } + Ok(Value::Object(resolved)) + } else { + object + .iter() + .map(|(key, value)| { + Ok(( + key.clone(), + resolve_value(value, root, stack, depth + 1, budget)?, + )) + }) + .collect::, _>>() + .map(Value::Object) + } + } + _ => Ok(value.clone()), + } +} + +fn local_pointer(reference: &str) -> Result { + let fragment = reference + .strip_prefix('#') + .expect("local references start with a fragment marker"); + percent_decode(fragment).ok_or_else(|| OpenApiError::ReferenceNotFound(reference.to_owned())) +} + +fn percent_decode(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = *bytes.get(index + 1)?; + let low = *bytes.get(index + 2)?; + decoded.push(hex(high)? * 16 + hex(low)?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn hex(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +#[derive(Clone)] +struct ParsedParameter { + binding: OpenApiParameterBinding, + schema: Value, +} + +fn parse_parameters( + value: Option<&Value>, + root: &Value, + path: &str, + budget: &mut CompileBudget, +) -> Result, OpenApiError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let values = value + .as_array() + .ok_or_else(|| invalid_operation("PATH", path, "parameters must be an array"))?; + if values.len() > MAX_PARAMETERS_PER_OPERATION { + return Err(limit("too_many_parameters")); + } + let mut parsed = Vec::with_capacity(values.len()); + for value in values { + let mut stack = Vec::new(); + let value = resolve_value(value, root, &mut stack, 0, budget)?; + let parameter = value + .as_object() + .ok_or_else(|| invalid_operation("PATH", path, "a parameter must be an object"))?; + let name = string_field(parameter, "name")?.to_owned(); + let location = match string_field(parameter, "in")? { + "path" => OpenApiParameterLocation::Path, + "query" => OpenApiParameterLocation::Query, + "header" => OpenApiParameterLocation::Header, + "cookie" => OpenApiParameterLocation::Cookie, + _ => { + return Err(invalid_operation( + "PATH", + path, + "a parameter location is invalid", + )); + } + }; + let required = location == OpenApiParameterLocation::Path + || parameter + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + let style = parameter + .get("style") + .and_then(Value::as_str) + .unwrap_or_else(|| default_style(location)) + .to_owned(); + let explode = parameter + .get("explode") + .and_then(Value::as_bool) + .unwrap_or_else(|| style == "form"); + let allow_reserved = parameter + .get("allowReserved") + .and_then(Value::as_bool) + .unwrap_or(false); + if allow_reserved { + return Err(invalid_operation( + "PATH", + path, + "allowReserved parameters are not supported", + )); + } + let supported_style = match location { + OpenApiParameterLocation::Path | OpenApiParameterLocation::Header => style == "simple", + OpenApiParameterLocation::Query => matches!( + style.as_str(), + "form" | "spaceDelimited" | "pipeDelimited" | "deepObject" + ), + OpenApiParameterLocation::Cookie => style == "form", + }; + if !supported_style { + return Err(invalid_operation( + "PATH", + path, + "the parameter serialization style is not supported", + )); + } + if parameter.contains_key("content") { + return Err(invalid_operation( + "PATH", + path, + "content-based parameters are not supported", + )); + } + let schema = if let Some(schema) = parameter.get("schema") { + schema.clone() + } else { + json!({}) + }; + parsed.push(ParsedParameter { + binding: OpenApiParameterBinding { + name, + location, + required, + style, + explode, + allow_reserved, + }, + schema, + }); + } + Ok(parsed) +} + +fn merge_parameters( + path_parameters: Vec, + operation_parameters: Vec, +) -> Vec { + let mut parameters = BTreeMap::new(); + for parameter in path_parameters.into_iter().chain(operation_parameters) { + parameters.insert( + (parameter.binding.location, parameter.binding.name.clone()), + parameter, + ); + } + parameters.into_values().collect() +} + +fn default_style(location: OpenApiParameterLocation) -> &'static str { + match location { + OpenApiParameterLocation::Path | OpenApiParameterLocation::Header => "simple", + OpenApiParameterLocation::Query | OpenApiParameterLocation::Cookie => "form", + } +} + +fn validate_path_parameters( + method: &str, + path: &str, + parameters: &[ParsedParameter], +) -> Result<(), OpenApiError> { + let mut template_names = Vec::new(); + let mut remainder = path; + while let Some(open) = remainder.find('{') { + let after_open = &remainder[open + 1..]; + let Some(close) = after_open.find('}') else { + return Err(invalid_operation( + method, + path, + "the path template has an unclosed variable", + )); + }; + let name = &after_open[..close]; + if name.is_empty() || name.contains('{') { + return Err(invalid_operation( + method, + path, + "the path template has an invalid variable", + )); + } + template_names.push(name); + remainder = &after_open[close + 1..]; + } + if remainder.contains('}') { + return Err(invalid_operation( + method, + path, + "the path template has an unmatched closing brace", + )); + } + let path_parameters = parameters + .iter() + .filter(|parameter| parameter.binding.location == OpenApiParameterLocation::Path) + .map(|parameter| parameter.binding.name.as_str()) + .collect::>(); + let same_members = template_names.len() == path_parameters.len() + && template_names + .iter() + .all(|name| path_parameters.iter().any(|parameter| parameter == name)); + if same_members { + Ok(()) + } else { + Err(invalid_operation( + method, + path, + "path template variables and path parameters must match", + )) + } +} + +fn select_server( + value: Option<&Value>, + root: &Value, + method: &str, + path: &str, + budget: &mut CompileBudget, +) -> Result { + let Some(value) = value else { + return Ok("/".to_owned()); + }; + let servers = value + .as_array() + .ok_or_else(|| invalid_operation(method, path, "servers must be an array"))?; + if servers.is_empty() { + return Ok("/".to_owned()); + } + let mut selected = None; + for server in servers { + let mut stack = Vec::new(); + let server = resolve_value(server, root, &mut stack, 0, budget)?; + let url = parse_server_url(&server, method, path)?; + if selected.is_none() { + selected = Some(url); + } + } + Ok(selected.expect("a non-empty server list selects its first server")) +} + +fn parse_server_url(server: &Value, method: &str, path: &str) -> Result { + let server = server + .as_object() + .ok_or_else(|| invalid_operation(method, path, "a server must be an object"))?; + let mut url = string_field(server, "url")?.to_owned(); + if let Some(variables) = server.get("variables").and_then(Value::as_object) { + for (name, variable) in variables { + let default = variable + .get("default") + .and_then(Value::as_str) + .ok_or_else(|| { + invalid_operation(method, path, "a server variable needs a default") + })?; + url = url.replace(&format!("{{{name}}}"), default); + } + } + validate_server_url(&url, method, path)?; + Ok(url) +} + +fn validate_server_url(url: &str, method: &str, path: &str) -> Result<(), OpenApiError> { + let base = Url::parse("https://executor.invalid/") + .expect("the fixed OpenAPI server validation base is valid"); + let parsed = Url::options() + .base_url(Some(&base)) + .parse(url) + .map_err(|_| invalid_operation(method, path, "the server URL is invalid"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(invalid_operation( + method, + path, + "the server URL scheme is not supported", + )); + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(invalid_operation( + method, + path, + "server URLs cannot contain userinfo, query parameters, or fragments", + )); + } + Ok(()) +} + +struct ParsedRequestBody { + binding: OpenApiRequestBodyBinding, + schemas: Vec, +} + +fn parse_request_body( + value: Option<&Value>, + root: &Value, + method: &str, + path: &str, + budget: &mut CompileBudget, +) -> Result, OpenApiError> { + let Some(value) = value else { + return Ok(None); + }; + let mut stack = Vec::new(); + let body = resolve_value(value, root, &mut stack, 0, budget)?; + let body = body + .as_object() + .ok_or_else(|| invalid_operation(method, path, "requestBody must be an object"))?; + let content = object_field(body, "content")?; + let mut media_types = content.keys().cloned().collect::>(); + if media_types.iter().any(|media_type| { + let base = media_type + .split_once(';') + .map_or(media_type.as_str(), |(base, _)| base) + .trim(); + !(base == "application/json" + || base.ends_with("+json") + || base == "text/plain" + || base == "application/x-www-form-urlencoded") + }) { + return Err(invalid_operation( + method, + path, + "the request body media type is not supported", + )); + } + media_types.sort_by(|left, right| { + media_rank(left) + .cmp(&media_rank(right)) + .then_with(|| left.cmp(right)) + }); + let Some(default_media_type) = media_types.first().cloned() else { + return Err(invalid_operation( + method, + path, + "requestBody content cannot be empty", + )); + }; + let schemas = media_types + .iter() + .map(|media_type| { + content + .get(media_type) + .and_then(Value::as_object) + .and_then(|media| media.get("schema")) + .cloned() + .unwrap_or_else(|| json!({})) + }) + .collect::>(); + for (media_type, schema) in media_types.iter().zip(&schemas) { + validate_request_body_schema(media_type, schema, method, path)?; + } + Ok(Some(ParsedRequestBody { + binding: OpenApiRequestBodyBinding { + required: body + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false), + default_media_type, + media_types, + }, + schemas, + })) +} + +fn validate_request_body_schema( + media_type: &str, + schema: &Value, + method: &str, + path: &str, +) -> Result<(), OpenApiError> { + let base = media_type + .split_once(';') + .map_or(media_type, |(base, _)| base) + .trim(); + if base == "text/plain" { + if schema.get("type").and_then(Value::as_str) != Some("string") { + return Err(invalid_operation( + method, + path, + "text/plain request bodies require a string schema", + )); + } + } else if base == "application/x-www-form-urlencoded" { + let schema = schema.as_object().ok_or_else(|| { + invalid_operation(method, path, "form request bodies require an object schema") + })?; + if schema.get("type").and_then(Value::as_str) != Some("object") { + return Err(invalid_operation( + method, + path, + "form request bodies require an object schema", + )); + } + if schema.get("additionalProperties") != Some(&Value::Bool(false)) { + return Err(invalid_operation( + method, + path, + "form request body properties must be declared scalar values", + )); + } + if let Some(properties) = schema.get("properties") { + let properties = properties.as_object().ok_or_else(|| { + invalid_operation(method, path, "form schema properties must be an object") + })?; + for property in properties.values() { + let scalar = property + .get("type") + .and_then(Value::as_str) + .is_some_and(|kind| { + matches!(kind, "string" | "number" | "integer" | "boolean") + }); + if !scalar { + return Err(invalid_operation( + method, + path, + "form request body properties must be scalar values", + )); + } + } + } + } + Ok(()) +} + +fn select_media(content: &Map) -> Option<(&String, &Map)> { + content + .iter() + .filter_map(|(kind, value)| value.as_object().map(|value| (kind, value))) + .min_by(|(left, _), (right, _)| { + media_rank(left) + .cmp(&media_rank(right)) + .then_with(|| left.cmp(right)) + }) +} + +fn media_rank(media_type: &str) -> u8 { + let media_type = media_type + .split_once(';') + .map_or(media_type, |(essence, _)| essence) + .trim(); + match media_type { + "application/json" => 0, + value if value.ends_with("+json") => 1, + "application/x-www-form-urlencoded" => 2, + "multipart/form-data" => 3, + value if value.starts_with("text/") => 4, + _ => 5, + } +} + +fn parse_security( + value: Option<&Value>, + schemes: Option<&Map>, + root: &Value, + method: &str, + path: &str, + budget: &mut CompileBudget, +) -> Result, OpenApiError> { + let Some(value) = value else { + return Ok(vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }]); + }; + let alternatives = value + .as_array() + .ok_or_else(|| invalid_operation(method, path, "security must be an array"))?; + if alternatives.len() > MAX_SECURITY_ALTERNATIVES { + return Err(limit("too_many_security_alternatives")); + } + if alternatives.is_empty() { + return Ok(vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }]); + } + let mut parsed = Vec::with_capacity(alternatives.len()); + for alternative in alternatives { + let alternative = alternative.as_object().ok_or_else(|| { + invalid_operation(method, path, "a security alternative must be an object") + })?; + if alternative.len() > MAX_SECURITY_REQUIREMENTS_PER_ALTERNATIVE { + return Err(limit("too_many_security_requirements")); + } + let mut requirements = Vec::with_capacity(alternative.len()); + for (scheme_name, scopes) in alternative { + let scheme_value = schemes + .and_then(|schemes| schemes.get(scheme_name)) + .ok_or_else(|| invalid_operation(method, path, "a security scheme is missing"))?; + let mut stack = Vec::new(); + let scheme_value = resolve_value(scheme_value, root, &mut stack, 0, budget)?; + let (scheme, oauth_flows) = parse_security_scheme( + scheme_value.as_object().ok_or_else(|| { + invalid_operation(method, path, "a security scheme must be an object") + })?, + method, + path, + )?; + let scopes = scopes + .as_array() + .ok_or_else(|| invalid_operation(method, path, "security scopes must be an array"))? + .iter() + .map(|scope| { + scope.as_str().map(str::to_owned).ok_or_else(|| { + invalid_operation(method, path, "a security scope must be a string") + }) + }) + .collect::, _>>()?; + requirements.push(OpenApiSecurityRequirement { + scheme_name: scheme_name.clone(), + scopes, + scheme, + oauth_flows, + }); + } + requirements.sort_by(|left, right| left.scheme_name.cmp(&right.scheme_name)); + let alternative = OpenApiSecurityAlternative { requirements }; + if security_alternative_has_carrier_conflict(&alternative) { + return Err(invalid_operation( + method, + path, + "security requirements in an AND alternative target the same carrier", + )); + } + parsed.push(alternative); + } + Ok(parsed) +} + +fn security_alternative_has_carrier_conflict(alternative: &OpenApiSecurityAlternative) -> bool { + let mut carriers = std::collections::BTreeSet::new(); + alternative + .requirements + .iter() + .filter_map(security_carrier) + .any(|carrier| !carriers.insert(carrier)) +} + +fn security_carrier(requirement: &OpenApiSecurityRequirement) -> Option { + match &requirement.scheme { + OpenApiSecurityScheme::ApiKey { name, location } => Some(match location { + OpenApiParameterLocation::Header => format!("header:{}", name.to_ascii_lowercase()), + OpenApiParameterLocation::Query => format!("query:{name}"), + OpenApiParameterLocation::Cookie => format!("cookie:{name}"), + OpenApiParameterLocation::Path => return None, + }), + OpenApiSecurityScheme::Http { .. } + | OpenApiSecurityScheme::OAuth2 + | OpenApiSecurityScheme::OpenIdConnect { .. } => Some("header:authorization".to_owned()), + OpenApiSecurityScheme::MutualTls => Some("tls:client_certificate".to_owned()), + } +} + +fn parse_security_scheme( + scheme: &Map, + method: &str, + path: &str, +) -> Result<(OpenApiSecurityScheme, Option), OpenApiError> { + match string_field(scheme, "type")? { + "apiKey" => { + let location = match string_field(scheme, "in")? { + "header" => OpenApiParameterLocation::Header, + "query" => OpenApiParameterLocation::Query, + "cookie" => OpenApiParameterLocation::Cookie, + _ => { + return Err(invalid_operation( + method, + path, + "an API key location is invalid", + )); + } + }; + Ok(( + OpenApiSecurityScheme::ApiKey { + name: string_field(scheme, "name")?.to_owned(), + location, + }, + None, + )) + } + "http" => { + let http_scheme = string_field(scheme, "scheme")?.to_ascii_lowercase(); + if !matches!(http_scheme.as_str(), "basic" | "bearer") { + return Err(invalid_operation( + method, + path, + "the HTTP authentication scheme is not supported", + )); + } + Ok(( + OpenApiSecurityScheme::Http { + scheme: http_scheme, + bearer_format: optional_string(scheme, "bearerFormat")?.map(str::to_owned), + }, + None, + )) + } + "oauth2" => Ok(( + OpenApiSecurityScheme::OAuth2, + Some(parse_oauth_flows(object_field(scheme, "flows")?)?), + )), + "openIdConnect" => Ok(( + OpenApiSecurityScheme::OpenIdConnect { + url: string_field(scheme, "openIdConnectUrl")?.to_owned(), + }, + None, + )), + "mutualTLS" => Err(invalid_operation( + method, + path, + "mutual TLS authentication is not supported", + )), + _ => Err(invalid_operation( + method, + path, + "a security scheme type is unsupported", + )), + } +} + +fn parse_oauth_flows(flows: &Map) -> Result { + Ok(OpenApiOAuthFlows { + implicit: flows + .get("implicit") + .map(|flow| parse_oauth_flow(flow, true, false)) + .transpose()?, + password: flows + .get("password") + .map(|flow| parse_oauth_flow(flow, false, true)) + .transpose()?, + client_credentials: flows + .get("clientCredentials") + .map(|flow| parse_oauth_flow(flow, false, true)) + .transpose()?, + authorization_code: flows + .get("authorizationCode") + .map(|flow| parse_oauth_flow(flow, true, true)) + .transpose()?, + }) +} + +fn parse_oauth_flow( + flow: &Value, + needs_authorization_url: bool, + needs_token_url: bool, +) -> Result { + let flow = flow.as_object().ok_or(OpenApiError::InvalidDocument( + "an OAuth flow must be an object", + ))?; + let authorization_url = optional_string(flow, "authorizationUrl")?.map(str::to_owned); + let token_url = optional_string(flow, "tokenUrl")?.map(str::to_owned); + if needs_authorization_url && authorization_url.is_none() { + return Err(OpenApiError::InvalidDocument( + "an OAuth flow is missing authorizationUrl", + )); + } + if needs_token_url && token_url.is_none() { + return Err(OpenApiError::InvalidDocument( + "an OAuth flow is missing tokenUrl", + )); + } + let scopes = object_field(flow, "scopes")? + .iter() + .map(|(scope, description)| { + description + .as_str() + .map(|description| (scope.clone(), description.to_owned())) + .ok_or(OpenApiError::InvalidDocument( + "an OAuth scope description must be a string", + )) + }) + .collect::>()?; + Ok(OpenApiOAuthFlow { + authorization_url, + token_url, + refresh_url: optional_string(flow, "refreshUrl")?.map(str::to_owned), + scopes, + }) +} + +fn build_input_schema( + parameters: &[ParsedParameter], + body: Option<&ParsedRequestBody>, + dialect: OpenApiSchemaDialect, +) -> Result { + let mut root_properties = Map::new(); + let mut root_required = Vec::new(); + let mut media_branches = None; + for location in [ + OpenApiParameterLocation::Path, + OpenApiParameterLocation::Query, + OpenApiParameterLocation::Header, + OpenApiParameterLocation::Cookie, + ] { + let matching = parameters + .iter() + .filter(|parameter| parameter.binding.location == location) + .collect::>(); + if matching.is_empty() { + continue; + } + let mut properties = Map::new(); + let mut required = Vec::new(); + for parameter in matching { + properties.insert( + parameter.binding.name.clone(), + normalized_input_schema(¶meter.schema, dialect)?, + ); + if parameter.binding.required { + required.push(Value::String(parameter.binding.name.clone())); + } + } + let mut schema = Map::from_iter([ + ("type".to_owned(), Value::String("object".to_owned())), + ("properties".to_owned(), Value::Object(properties)), + ("additionalProperties".to_owned(), Value::Bool(false)), + ]); + if !required.is_empty() { + schema.insert("required".to_owned(), Value::Array(required)); + root_required.push(Value::String(location.input_key().to_owned())); + } + root_properties.insert(location.input_key().to_owned(), Value::Object(schema)); + } + if let Some(body) = body { + let normalized_schemas = body + .schemas + .iter() + .map(|schema| normalized_input_schema(schema, dialect)) + .collect::, _>>()?; + let schema = if normalized_schemas.len() == 1 { + normalized_schemas[0].clone() + } else { + media_branches = Some( + body.binding + .media_types + .iter() + .zip(&normalized_schemas) + .map(|(media_type, schema)| { + let mut required = Vec::new(); + if body.binding.required { + required.push(Value::String("body".to_owned())); + } + if media_type != &body.binding.default_media_type { + required.push(Value::String("contentType".to_owned())); + } + let mut branch = Map::from_iter([ + ("type".to_owned(), Value::String("object".to_owned())), + ( + "properties".to_owned(), + json!({ + "body": schema, + "contentType": { "const": media_type } + }), + ), + ]); + if !required.is_empty() { + branch.insert("required".to_owned(), Value::Array(required)); + } + Value::Object(branch) + }) + .collect::>(), + ); + Value::Bool(true) + }; + root_properties.insert("body".to_owned(), schema); + root_properties.insert( + "contentType".to_owned(), + json!({ + "type": "string", + "enum": body.binding.media_types, + "default": body.binding.default_media_type + }), + ); + if body.binding.required { + root_required.push(Value::String("body".to_owned())); + } + } + let mut schema = Map::from_iter([ + ( + "$schema".to_owned(), + Value::String("https://json-schema.org/draft/2020-12/schema".to_owned()), + ), + ("type".to_owned(), Value::String("object".to_owned())), + ("properties".to_owned(), Value::Object(root_properties)), + ("additionalProperties".to_owned(), Value::Bool(false)), + ]); + if !root_required.is_empty() { + schema.insert("required".to_owned(), Value::Array(root_required)); + } + if let Some(media_branches) = media_branches { + schema.insert("oneOf".to_owned(), Value::Array(media_branches)); + } + Ok(Value::Object(schema)) +} + +fn normalized_input_schema( + schema: &Value, + dialect: OpenApiSchemaDialect, +) -> Result { + let mut schema = schema.clone(); + validate_schema_dialect_declarations(&schema, dialect)?; + normalize_request_schema(&mut schema); + if matches!(dialect, OpenApiSchemaDialect::OpenApi30) { + normalize_openapi30_schema(&mut schema); + } + Ok(schema) +} + +fn validate_schema_dialect_declarations( + value: &Value, + dialect: OpenApiSchemaDialect, +) -> Result<(), OpenApiError> { + let Value::Object(schema) = value else { + return Ok(()); + }; + if let Some(declared) = schema.get("$schema") { + let declared = declared.as_str().ok_or(OpenApiError::InvalidDocument( + "a Schema Object $schema declaration must be a URI string", + ))?; + let declared = declared.strip_suffix('#').unwrap_or(declared); + if matches!(dialect, OpenApiSchemaDialect::OpenApi30) + || !matches!(declared, OAS31_BASE_DIALECT | JSON_SCHEMA_2020_12_DIALECT) + { + return Err(OpenApiError::InvalidDocument( + "a Schema Object uses an unsupported JSON Schema dialect", + )); + } + } + for keyword in [ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + ] { + if let Some(child) = schema.get(keyword) { + validate_schema_dialect_declarations(child, dialect)?; + } + } + for keyword in [ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", + ] { + if let Some(children) = schema.get(keyword).and_then(Value::as_object) { + for child in children.values() { + validate_schema_dialect_declarations(child, dialect)?; + } + } + } + for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] { + if let Some(children) = schema.get(keyword).and_then(Value::as_array) { + for child in children { + validate_schema_dialect_declarations(child, dialect)?; + } + } + } + Ok(()) +} + +fn normalize_request_schema(value: &mut Value) { + let Value::Object(schema) = value else { + return; + }; + for keyword in [ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + ] { + if let Some(child) = schema.get_mut(keyword) { + normalize_request_schema(child); + } + } + for keyword in [ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", + ] { + if let Some(children) = schema.get_mut(keyword).and_then(Value::as_object_mut) { + children.values_mut().for_each(normalize_request_schema); + } + } + for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] { + if let Some(children) = schema.get_mut(keyword).and_then(Value::as_array_mut) { + children.iter_mut().for_each(normalize_request_schema); + } + } + let read_only = schema + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .filter(|(_, property)| property.get("readOnly") == Some(&Value::Bool(true))) + .map(|(name, _)| name.clone()) + .collect::>() + }) + .unwrap_or_default(); + if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) { + required.retain(|name| name.as_str().is_none_or(|name| !read_only.contains(name))); + } +} + +fn normalize_openapi30_schema(value: &mut Value) { + let Value::Object(schema) = value else { + return; + }; + for keyword in [ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + ] { + if let Some(child) = schema.get_mut(keyword) { + normalize_openapi30_schema(child); + } + } + for keyword in [ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", + ] { + if let Some(children) = schema.get_mut(keyword).and_then(Value::as_object_mut) { + children.values_mut().for_each(normalize_openapi30_schema); + } + } + for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] { + if let Some(children) = schema.get_mut(keyword).and_then(Value::as_array_mut) { + children.iter_mut().for_each(normalize_openapi30_schema); + } + } + normalize_openapi30_exclusive_bound(schema, "minimum", "exclusiveMinimum"); + normalize_openapi30_exclusive_bound(schema, "maximum", "exclusiveMaximum"); + if schema.remove("nullable") == Some(Value::Bool(true)) { + match schema.remove("type") { + Some(Value::String(schema_type)) => { + schema.insert( + "type".to_owned(), + Value::Array(vec![ + Value::String(schema_type), + Value::String("null".to_owned()), + ]), + ); + } + Some(Value::Array(mut schema_types)) => { + if !schema_types.iter().any(|schema_type| schema_type == "null") { + schema_types.push(Value::String("null".to_owned())); + } + schema.insert("type".to_owned(), Value::Array(schema_types)); + } + Some(schema_type) => { + schema.insert("type".to_owned(), schema_type); + } + None => {} + } + } +} + +fn normalize_openapi30_exclusive_bound( + schema: &mut Map, + inclusive_name: &str, + exclusive_name: &str, +) { + match schema.remove(exclusive_name) { + Some(Value::Bool(true)) => { + if let Some(bound) = schema.remove(inclusive_name) { + schema.insert(exclusive_name.to_owned(), bound); + } + } + Some(Value::Bool(false)) | None => {} + Some(value) => { + schema.insert(exclusive_name.to_owned(), value); + } + } +} + +fn parse_output_schema( + value: Option<&Value>, + root: &Value, + method: &str, + path: &str, + budget: &mut CompileBudget, +) -> Result, OpenApiError> { + let Some(responses) = value else { + return Ok(None); + }; + let responses = responses + .as_object() + .ok_or_else(|| invalid_operation(method, path, "responses must be an object"))?; + let response = responses + .iter() + .filter(|(status, _)| { + status.len() == 3 + && status.starts_with('2') + && status[1..] + .chars() + .all(|character| character.is_ascii_digit()) + }) + .min_by_key(|(status, _)| *status) + .map(|(_, response)| response) + .or_else(|| responses.get("2XX")) + .or_else(|| responses.get("default")); + let Some(response) = response else { + return Ok(None); + }; + let mut stack = Vec::new(); + let response = resolve_value(response, root, &mut stack, 0, budget)?; + let content = response + .as_object() + .and_then(|response| response.get("content")) + .and_then(Value::as_object); + Ok(content + .and_then(select_media) + .and_then(|(_, media)| media.get("schema")) + .cloned()) +} + +fn stable_key(method: &str, path: &str) -> String { + let digest = Sha256::digest(format!("{}\n{path}", method.to_ascii_uppercase())); + let digest = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("openapi:v1:{digest}") +} + +fn fallback_name(method: &str, path: &str) -> String { + let mut name = method.to_owned(); + for segment in path.split('/') { + let segment = segment.trim_matches(['{', '}']); + if !segment.is_empty() { + name.push('_'); + name.push_str(segment); + } + } + name +} + +fn intrinsic_mode(method: &str) -> ToolMode { + match method { + "get" | "head" | "options" => ToolMode::Enabled, + "post" | "put" | "patch" | "delete" => ToolMode::Ask, + _ => ToolMode::Disabled, + } +} + +fn object_field<'a>( + object: &'a Map, + field: &'static str, +) -> Result<&'a Map, OpenApiError> { + object + .get(field) + .and_then(Value::as_object) + .ok_or(OpenApiError::InvalidDocument( + "a required object field is missing", + )) +} + +fn string_field<'a>( + object: &'a Map, + field: &'static str, +) -> Result<&'a str, OpenApiError> { + object + .get(field) + .and_then(Value::as_str) + .ok_or(OpenApiError::InvalidDocument( + "a required string field is missing", + )) +} + +fn optional_string<'a>( + object: &'a Map, + field: &'static str, +) -> Result, OpenApiError> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value)), + Some(_) => Err(OpenApiError::InvalidDocument( + "an optional string field is invalid", + )), + } +} + +fn invalid_operation(method: &str, path: &str, message: &'static str) -> OpenApiError { + OpenApiError::InvalidOperation { + method: method.to_ascii_uppercase(), + path: path.to_owned(), + message, + } +} + +fn limit(code: &'static str) -> OpenApiError { + OpenApiError::LimitExceeded { code } +} + +#[cfg(test)] +mod tests { + use super::{percent_decode, stable_key}; + + #[test] + fn percent_decoding_handles_local_pointer_fragments() { + assert_eq!(percent_decode("/a%20b"), Some("/a b".to_owned())); + assert_eq!(percent_decode("/%GG"), None); + } + + #[test] + fn stable_keys_are_case_normalized_and_path_sensitive() { + assert_eq!(stable_key("get", "/pets"), stable_key("GET", "/pets")); + assert_ne!(stable_key("get", "/pets"), stable_key("get", "/pets/")); + } +} diff --git a/src/outbound.rs b/src/outbound.rs new file mode 100644 index 000000000..b5eac094f --- /dev/null +++ b/src/outbound.rs @@ -0,0 +1,633 @@ +use std::{ + collections::HashSet, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Duration, +}; + +use reqwest::{ + Method, StatusCode, + header::{ + ACCEPT_ENCODING, CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH, HeaderMap, HeaderName, + LOCATION, REFERER, TE, TRAILER, TRANSFER_ENCODING, UPGRADE, + }, + redirect::Policy, +}; +use thiserror::Error; +use url::{Host, Url}; + +const DEFAULT_MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024; +const DEFAULT_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; +const DEFAULT_MAX_HEADER_BYTES: usize = 64 * 1024; +const DEFAULT_MAX_REDIRECTS: usize = 3; +const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_URL_BYTES: usize = 16 * 1024; +const MAX_DNS_ADDRESSES: usize = 32; +const AWS_METADATA_IPV6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254); +const GOOGLE_METADATA_IPV6: Ipv6Addr = Ipv6Addr::new(0xfd20, 0x00ce, 0, 0, 0, 0, 0, 0x0254); +const ORACLE_METADATA_IPV6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0x00c1, 0, 0, 0, 0, 0xa9fe, 0xa9fe); + +#[derive(Clone, Debug)] +pub struct OutboundPolicy { + pub allow_private_networks: bool, + pub max_request_bytes: usize, + pub max_response_bytes: usize, + pub max_header_bytes: usize, + pub max_redirects: usize, + pub connect_timeout: Duration, + pub request_timeout: Duration, +} + +impl Default for OutboundPolicy { + fn default() -> Self { + Self { + allow_private_networks: false, + max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_header_bytes: DEFAULT_MAX_HEADER_BYTES, + max_redirects: DEFAULT_MAX_REDIRECTS, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AddressClass { + Public, + Private, + Forbidden, +} + +#[derive(Debug, Error)] +pub enum OutboundError { + #[error("the outbound URL is invalid")] + InvalidUrl, + #[error("the outbound URL must use HTTP or HTTPS")] + UnsupportedScheme, + #[error("the outbound URL must not contain credentials")] + CredentialsNotAllowed, + #[error("the outbound URL must not contain a fragment")] + FragmentNotAllowed, + #[error("the outbound URL must contain a host")] + MissingHost, + #[error("the outbound URL has no usable port")] + MissingPort, + #[error("the outbound host resolves to a private address")] + PrivateAddress, + #[error("the outbound host resolves to a forbidden address")] + ForbiddenAddress, + #[error("the outbound host could not be resolved")] + DnsResolution, + #[error("the outbound request contains a forbidden header")] + ForbiddenHeader, + #[error("the outbound request method is not allowed")] + ForbiddenMethod, + #[error("the outbound request headers are too large")] + RequestHeadersTooLarge, + #[error("the outbound request body is too large")] + RequestBodyTooLarge, + #[error("the outbound HTTP client could not be initialized")] + ClientInitialization, + #[error("the outbound request timed out")] + Timeout, + #[error("the outbound connection failed")] + Connection, + #[error("the outbound request failed")] + Request, + #[error("the upstream response headers are too large")] + ResponseHeadersTooLarge, + #[error("the upstream response body is too large")] + ResponseBodyTooLarge, + #[error("the upstream response uses an unsupported content encoding")] + UnsupportedContentEncoding, + #[error("the upstream redirect has no valid Location header")] + InvalidRedirect, + #[error("the upstream redirect would downgrade HTTPS to HTTP")] + RedirectDowngrade, + #[error("the upstream redirect looped")] + RedirectLoop, + #[error("the upstream response exceeded the redirect limit")] + TooManyRedirects, + #[error("the upstream server returned HTTP {status}")] + UpstreamStatus { status: StatusCode }, +} + +impl OutboundError { + pub fn code(&self) -> &'static str { + match self { + Self::InvalidUrl => "invalid_outbound_url", + Self::UnsupportedScheme => "unsupported_outbound_scheme", + Self::CredentialsNotAllowed => "outbound_credentials_not_allowed", + Self::FragmentNotAllowed => "outbound_fragment_not_allowed", + Self::MissingHost | Self::MissingPort => "invalid_outbound_host", + Self::PrivateAddress => "private_network_denied", + Self::ForbiddenAddress => "forbidden_network_target", + Self::DnsResolution => "dns_resolution_failed", + Self::ForbiddenHeader => "forbidden_outbound_header", + Self::ForbiddenMethod => "forbidden_outbound_method", + Self::RequestHeadersTooLarge => "outbound_headers_too_large", + Self::RequestBodyTooLarge => "outbound_request_too_large", + Self::ClientInitialization => "outbound_client_failed", + Self::Timeout => "upstream_timeout", + Self::Connection => "upstream_connection_failed", + Self::Request => "upstream_request_failed", + Self::ResponseHeadersTooLarge => "upstream_headers_too_large", + Self::ResponseBodyTooLarge => "upstream_response_too_large", + Self::UnsupportedContentEncoding => "unsupported_content_encoding", + Self::InvalidRedirect => "invalid_upstream_redirect", + Self::RedirectDowngrade => "redirect_downgrade_denied", + Self::RedirectLoop => "upstream_redirect_loop", + Self::TooManyRedirects => "too_many_upstream_redirects", + Self::UpstreamStatus { .. } => "upstream_http_error", + } + } +} + +#[derive(Clone)] +pub struct OutboundRequest { + pub method: Method, + pub url: Url, + pub headers: HeaderMap, + pub body: Vec, +} + +impl OutboundRequest { + pub fn new(method: Method, url: Url) -> Self { + Self { + method, + url, + headers: HeaderMap::new(), + body: Vec::new(), + } + } +} + +pub struct OutboundResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub body: Vec, + pub final_url: Url, +} + +#[derive(Clone, Debug)] +pub struct HardenedHttpClient { + policy: OutboundPolicy, +} + +pub fn parse_url(input: &str, policy: &OutboundPolicy) -> Result { + let url = Url::parse(input).map_err(|_| OutboundError::InvalidUrl)?; + validate_url(&url, policy)?; + Ok(url) +} + +impl HardenedHttpClient { + pub fn new(policy: OutboundPolicy) -> Self { + Self { policy } + } + + pub async fn execute( + &self, + request: OutboundRequest, + ) -> Result { + validate_request(&request, &self.policy)?; + tokio::time::timeout(self.policy.request_timeout, self.execute_once(request)) + .await + .map_err(|_| OutboundError::Timeout)? + } + + pub async fn fetch_spec( + &self, + url: Url, + headers: HeaderMap, + ) -> Result { + tokio::time::timeout( + self.policy.request_timeout, + self.fetch_spec_with_redirects(url, headers), + ) + .await + .map_err(|_| OutboundError::Timeout)? + } + + async fn fetch_spec_with_redirects( + &self, + url: Url, + headers: HeaderMap, + ) -> Result { + validate_headers(&headers, self.policy.max_header_bytes)?; + let mut current = url; + let mut headers = headers; + let mut visited = HashSet::new(); + + for redirect_count in 0..=self.policy.max_redirects { + validate_url(¤t, &self.policy)?; + if !visited.insert(current.as_str().to_owned()) { + return Err(OutboundError::RedirectLoop); + } + + let response = self + .execute_once(OutboundRequest { + method: Method::GET, + url: current.clone(), + headers: headers.clone(), + body: Vec::new(), + }) + .await?; + if !is_redirect(response.status) { + if response.status.is_success() { + return Ok(response); + } + return Err(OutboundError::UpstreamStatus { + status: response.status, + }); + } + if redirect_count == self.policy.max_redirects { + return Err(OutboundError::TooManyRedirects); + } + + let next = redirect_target(¤t, response.headers.get(LOCATION), &self.policy)?; + if !same_origin(¤t, &next) { + strip_cross_origin_headers(&mut headers); + } + current = next; + } + + Err(OutboundError::TooManyRedirects) + } + + async fn execute_once( + &self, + mut request: OutboundRequest, + ) -> Result { + validate_request(&request, &self.policy)?; + let resolved = resolve_target(&request.url, &self.policy).await?; + if !request.headers.contains_key(ACCEPT_ENCODING) { + request.headers.insert( + ACCEPT_ENCODING, + "identity".parse().expect("static header is valid"), + ); + } + validate_headers(&request.headers, self.policy.max_header_bytes)?; + + let mut builder = reqwest::Client::builder() + .no_proxy() + .redirect(Policy::none()) + .referer(false) + .connect_timeout(self.policy.connect_timeout) + .timeout(self.policy.request_timeout) + .pool_max_idle_per_host(0); + if let Some(hostname) = resolved.hostname.as_deref() { + builder = builder.resolve_to_addrs(hostname, &resolved.addresses); + } + let client = builder + .build() + .map_err(|_| OutboundError::ClientInitialization)?; + let response = client + .request(request.method, request.url.clone()) + .headers(request.headers) + .body(request.body) + .send() + .await + .map_err(map_reqwest_error)?; + validate_response_headers(response.headers(), &self.policy)?; + let status = response.status(); + let headers = response.headers().clone(); + let mut body = Vec::new(); + let mut response = response; + while let Some(chunk) = response.chunk().await.map_err(map_reqwest_error)? { + let next_length = body + .len() + .checked_add(chunk.len()) + .ok_or(OutboundError::ResponseBodyTooLarge)?; + if next_length > self.policy.max_response_bytes { + return Err(OutboundError::ResponseBodyTooLarge); + } + body.extend_from_slice(&chunk); + } + Ok(OutboundResponse { + status, + headers, + body, + final_url: request.url, + }) + } +} + +struct ResolvedTarget { + hostname: Option, + addresses: Vec, +} + +async fn resolve_target( + url: &Url, + policy: &OutboundPolicy, +) -> Result { + validate_url(url, policy)?; + let port = url + .port_or_known_default() + .ok_or(OutboundError::MissingPort)?; + match url.host().ok_or(OutboundError::MissingHost)? { + Host::Ipv4(address) => { + validate_address(IpAddr::V4(address), policy)?; + Ok(ResolvedTarget { + hostname: None, + addresses: vec![SocketAddr::new(IpAddr::V4(address), port)], + }) + } + Host::Ipv6(address) => { + let address = canonical_ip(IpAddr::V6(address)); + validate_address(address, policy)?; + Ok(ResolvedTarget { + hostname: None, + addresses: vec![SocketAddr::new(address, port)], + }) + } + Host::Domain(hostname) => { + let mut addresses = tokio::time::timeout( + policy.connect_timeout, + tokio::net::lookup_host((hostname, port)), + ) + .await + .map_err(|_| OutboundError::Timeout)? + .map_err(|_| OutboundError::DnsResolution)? + .map(|address| SocketAddr::new(canonical_ip(address.ip()), address.port())) + .take(MAX_DNS_ADDRESSES + 1) + .collect::>(); + if addresses.len() > MAX_DNS_ADDRESSES { + return Err(OutboundError::DnsResolution); + } + addresses.sort_unstable(); + addresses.dedup(); + if addresses.is_empty() { + return Err(OutboundError::DnsResolution); + } + for address in &addresses { + validate_address(address.ip(), policy)?; + } + Ok(ResolvedTarget { + hostname: Some(hostname.to_owned()), + addresses, + }) + } + } +} + +pub fn validate_url(url: &Url, policy: &OutboundPolicy) -> Result<(), OutboundError> { + if url.as_str().len() > MAX_URL_BYTES { + return Err(OutboundError::InvalidUrl); + } + if !matches!(url.scheme(), "http" | "https") { + return Err(OutboundError::UnsupportedScheme); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(OutboundError::CredentialsNotAllowed); + } + if url.fragment().is_some() { + return Err(OutboundError::FragmentNotAllowed); + } + let host = url.host().ok_or(OutboundError::MissingHost)?; + if url.port_or_known_default().is_none_or(|port| port == 0) { + return Err(OutboundError::MissingPort); + } + match host { + Host::Ipv4(address) => validate_address(IpAddr::V4(address), policy), + Host::Ipv6(address) => validate_address(canonical_ip(IpAddr::V6(address)), policy), + Host::Domain(_) => Ok(()), + } +} + +pub fn classify_ip(address: IpAddr) -> AddressClass { + match canonical_ip(address) { + IpAddr::V4(address) => classify_ipv4(address), + IpAddr::V6(address) => classify_ipv6(address), + } +} + +fn classify_ipv4(address: Ipv4Addr) -> AddressClass { + let octets = address.octets(); + if octets == [100, 100, 100, 200] { + return AddressClass::Forbidden; + } + if octets[0] == 10 + || octets[0] == 127 + || (octets[0] == 172 && (16..=31).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 168) + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + { + return AddressClass::Private; + } + if octets[0] == 0 + || (octets[0] == 169 && octets[1] == 254) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 2) + || (octets[0] == 192 && octets[1] == 88 && octets[2] == 99) + || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) + || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100) + || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113) + || octets[0] >= 224 + { + AddressClass::Forbidden + } else { + AddressClass::Public + } +} + +fn classify_ipv6(address: Ipv6Addr) -> AddressClass { + if matches!( + address, + AWS_METADATA_IPV6 | GOOGLE_METADATA_IPV6 | ORACLE_METADATA_IPV6 + ) { + return AddressClass::Forbidden; + } + if address.is_loopback() || (address.segments()[0] & 0xfe00) == 0xfc00 { + return AddressClass::Private; + } + let segments = address.segments(); + let globally_routable_prefix = (segments[0] & 0xe000) == 0x2000; + let documentation = segments[0] == 0x2001 && segments[1] == 0x0db8; + let ietf_protocol_assignments = segments[0] == 0x2001 && segments[1] <= 0x01ff; + let benchmarking = segments[0] == 0x2001 && segments[1] == 0x0002; + let teredo = segments[0] == 0x2001 && segments[1] == 0; + let orchid = segments[0] == 0x2001 && matches!(segments[1] & 0xfff0, 0x0010 | 0x0020); + let documentation_v2 = segments[0] == 0x3fff && (segments[1] & 0xf000) == 0; + let discard_only = segments[0] == 0x0100 && segments[1..4] == [0, 0, 0]; + let translation_private = segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1; + let link_local = (segments[0] & 0xffc0) == 0xfe80; + let site_local = (segments[0] & 0xffc0) == 0xfec0; + if !globally_routable_prefix + || documentation + || ietf_protocol_assignments + || benchmarking + || teredo + || orchid + || documentation_v2 + || discard_only + || translation_private + || link_local + || site_local + || address.is_multicast() + || address.is_unspecified() + { + AddressClass::Forbidden + } else if segments[0] == 0x2002 { + let embedded = Ipv4Addr::new( + (segments[1] >> 8) as u8, + segments[1] as u8, + (segments[2] >> 8) as u8, + segments[2] as u8, + ); + classify_ipv4(embedded) + } else { + AddressClass::Public + } +} + +fn validate_address(address: IpAddr, policy: &OutboundPolicy) -> Result<(), OutboundError> { + match classify_ip(address) { + AddressClass::Public => Ok(()), + AddressClass::Private if policy.allow_private_networks => Ok(()), + AddressClass::Private => Err(OutboundError::PrivateAddress), + AddressClass::Forbidden => Err(OutboundError::ForbiddenAddress), + } +} + +fn canonical_ip(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + } +} + +pub fn redirect_target( + current: &Url, + location: Option<&reqwest::header::HeaderValue>, + policy: &OutboundPolicy, +) -> Result { + let location = location + .and_then(|value| value.to_str().ok()) + .ok_or(OutboundError::InvalidRedirect)?; + let next = current + .join(location) + .map_err(|_| OutboundError::InvalidRedirect)?; + validate_url(&next, policy)?; + if current.scheme() == "https" && next.scheme() != "https" { + return Err(OutboundError::RedirectDowngrade); + } + Ok(next) +} + +fn validate_request( + request: &OutboundRequest, + policy: &OutboundPolicy, +) -> Result<(), OutboundError> { + validate_url(&request.url, policy)?; + if matches!(request.method, Method::TRACE | Method::CONNECT) { + return Err(OutboundError::ForbiddenMethod); + } + validate_headers(&request.headers, policy.max_header_bytes)?; + if request.body.len() > policy.max_request_bytes { + return Err(OutboundError::RequestBodyTooLarge); + } + Ok(()) +} + +fn validate_headers(headers: &HeaderMap, max_bytes: usize) -> Result<(), OutboundError> { + let mut total = 0_usize; + for (name, value) in headers { + if forbidden_request_header(name) { + return Err(OutboundError::ForbiddenHeader); + } + total = total + .checked_add(name.as_str().len()) + .and_then(|length| length.checked_add(value.as_bytes().len())) + .ok_or(OutboundError::RequestHeadersTooLarge)?; + if total > max_bytes { + return Err(OutboundError::RequestHeadersTooLarge); + } + } + Ok(()) +} + +fn forbidden_request_header(name: &HeaderName) -> bool { + name == CONNECTION + || name == CONTENT_LENGTH + || name == TE + || name == TRAILER + || name == TRANSFER_ENCODING + || name == UPGRADE + || name == REFERER + || name == "host" + || name == "keep-alive" + || name.as_str().starts_with("proxy-") +} + +fn validate_response_headers( + headers: &HeaderMap, + policy: &OutboundPolicy, +) -> Result<(), OutboundError> { + let mut total = 0_usize; + for (name, value) in headers { + total = total + .checked_add(name.as_str().len()) + .and_then(|length| length.checked_add(value.as_bytes().len())) + .ok_or(OutboundError::ResponseHeadersTooLarge)?; + if total > policy.max_header_bytes { + return Err(OutboundError::ResponseHeadersTooLarge); + } + } + for value in headers.get_all(CONTENT_ENCODING) { + if !value.as_bytes().eq_ignore_ascii_case(b"identity") { + return Err(OutboundError::UnsupportedContentEncoding); + } + } + let mut declared_length = None; + for value in headers.get_all(CONTENT_LENGTH) { + let length = value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or(OutboundError::ResponseHeadersTooLarge)?; + if declared_length.is_some_and(|previous| previous != length) { + return Err(OutboundError::ResponseHeadersTooLarge); + } + if length > policy.max_response_bytes as u64 { + return Err(OutboundError::ResponseBodyTooLarge); + } + declared_length = Some(length); + } + Ok(()) +} + +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() + && left.host() == right.host() + && left.port_or_known_default() == right.port_or_known_default() +} + +fn strip_cross_origin_headers(headers: &mut HeaderMap) { + headers.clear(); +} + +fn is_redirect(status: StatusCode) -> bool { + matches!( + status, + StatusCode::MOVED_PERMANENTLY + | StatusCode::FOUND + | StatusCode::SEE_OTHER + | StatusCode::TEMPORARY_REDIRECT + | StatusCode::PERMANENT_REDIRECT + ) +} + +fn map_reqwest_error(error: reqwest::Error) -> OutboundError { + if error.is_timeout() { + OutboundError::Timeout + } else if error.is_connect() { + OutboundError::Connection + } else { + OutboundError::Request + } +} diff --git a/tests/catalog.rs b/tests/catalog.rs index ee42835e4..79e6be26d 100644 --- a/tests/catalog.rs +++ b/tests/catalog.rs @@ -10,8 +10,9 @@ use executor::{ catalog::{ ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, ListToolsFilter, ModeProvenance, NewRequestLog, RequestOutcome, RequestSurface, SourceKind, - StagedArtifact, StagedTool, ToolMode, UpdateSource, + StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, ToolMode, UpdateSource, }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, }; use http_body_util::BodyExt; use serde_json::{Map, Value, json}; @@ -48,11 +49,20 @@ impl TestExecutor { } async fn source(&self, preferred_slug: &str) -> executor::catalog::SourceRecord { + self.source_with_kind(preferred_slug, SourceKind::Graphql) + .await + } + + async fn source_with_kind( + &self, + preferred_slug: &str, + kind: SourceKind, + ) -> executor::catalog::SourceRecord { self.app .catalog() .create_source( CreateSource { - kind: SourceKind::Openapi, + kind, preferred_slug: preferred_slug.to_owned(), display_name: preferred_slug.to_owned(), description: None, @@ -169,6 +179,23 @@ fn staged(stable_key: &str, preferred_name: &str, mode: ToolMode) -> StagedTool } } +fn openapi_binding(stable_key: &str, method: &str) -> StagedToolBinding { + StagedToolBinding { + stable_key: stable_key.to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: method.to_owned(), + path_template: format!("/{stable_key}"), + server_url: "https://catalog.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + } +} + #[tokio::test] async fn catalog_migration_is_strict_and_enforces_foreign_keys_and_constraints() { let executor = TestExecutor::new().await; @@ -441,6 +468,203 @@ async fn refresh_is_atomic_and_preserves_overrides_through_tombstones() { assert_eq!(restored.mode_override, Some(ToolMode::Disabled)); } +#[tokio::test] +async fn generic_sync_rejects_unbound_openapi_tools_without_changing_catalog_state() { + let executor = TestExecutor::new().await; + let source = executor + .source_with_kind("bound-openapi", SourceKind::Openapi) + .await; + executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + vec![openapi_binding("alpha", "GET")], + AuditContext::system(None), + ) + .await + .expect("bound OpenAPI catalog should sync"); + + let source_before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_revision_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let tool_before = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + let binding_before = executor + .app + .catalog() + .tool_binding(&tool_before.id) + .await + .expect("binding should exist"); + + let error = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source_before.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "replacement".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![ + staged("alpha", "Changed Alpha", ToolMode::Ask), + staged("beta", "Beta", ToolMode::Enabled), + ], + }, + AuditContext::system(None), + ) + .await + .expect_err("generic sync must not leave active OpenAPI tools unbound"); + assert!(matches!( + error, + CatalogError::Validation { + code: "incomplete_tool_bindings", + .. + } + )); + + let source_after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(source_after.revision, source_before.revision); + assert_eq!( + source_after.catalog_revision, + source_before.catalog_revision + ); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_revision_before + ); + let tools_after = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("original tool should list") + .items; + assert_eq!(tools_after.len(), 1); + assert_eq!(tools_after[0].id, tool_before.id); + assert_eq!(tools_after[0].revision, tool_before.revision); + assert_eq!(tools_after[0].display_name, tool_before.display_name); + let binding_after = executor + .app + .catalog() + .tool_binding(&tool_before.id) + .await + .expect("original binding should remain"); + assert_eq!(binding_after.binding, binding_before.binding); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM source_artifacts WHERE source_id = ?",) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 0 + ); +} + +#[tokio::test] +async fn non_openapi_sources_reject_openapi_bindings() { + let executor = TestExecutor::new().await; + let source = executor.source("graphql-bindings").await; + executor + .sync( + &source.id, + vec![staged("query", "Query", ToolMode::Enabled)], + ) + .await; + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let sync_error = executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("query", "Query", ToolMode::Enabled)], + }, + vec![openapi_binding("query", "POST")], + AuditContext::system(None), + ) + .await + .expect_err("GraphQL refresh must reject OpenAPI bindings"); + assert!(matches!( + sync_error, + CatalogError::Validation { + code: "invalid_source_kind", + .. + } + )); + + let source_after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(source_after.revision, current.revision); + assert_eq!(source_after.catalog_revision, current.catalog_revision); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tool_bindings JOIN tools ON tools.id = tool_bindings.tool_id \ + WHERE tools.source_id = ?", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("binding count should read"), + 0 + ); +} + #[tokio::test] async fn refresh_rejects_oversized_serialized_schema_payloads_before_writing() { let executor = TestExecutor::new().await; diff --git a/tests/invocation_preparation.rs b/tests/invocation_preparation.rs new file mode 100644 index 000000000..0959f91a8 --- /dev/null +++ b/tests/invocation_preparation.rs @@ -0,0 +1,177 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +async fn imported_source() -> (tempfile::TempDir, ExecutorApp, String, String) { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let (source, _) = app + .catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "weather".to_owned(), + display_name: "Weather".to_owned(), + description: None, + configuration: Map::from_iter([( + "displayUrl".to_owned(), + json!("https://weather.example.test/openapi.json"), + )]), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ "credentials": { "schemes": {} } }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "get-weather".to_owned(), + preferred_name: "getWeather".to_owned(), + display_name: "Get weather".to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": { "query": { "type": "object" } } + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Enabled, + }], + }, + vec![StagedToolBinding { + stable_key: "get-weather".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: "/weather".to_owned(), + server_url: "https://weather.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }], + AuditContext::system(Some("initial-import")), + ) + .await + .expect("source should import atomically"); + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tool should list") + .items + .pop() + .expect("tool should exist"); + (directory, app, source.id, tool.id) +} + +#[tokio::test] +async fn invocation_preparation_is_one_typed_catalog_snapshot() { + let (_directory, app, source_id, tool_id) = imported_source().await; + let lease = app + .catalog() + .prepare_invocation("weather.get_weather") + .await + .expect("invocation should prepare"); + + assert_eq!(lease.lookup().source_id, source_id); + assert_eq!(lease.lookup().tool_id, tool_id); + assert_eq!(lease.lookup().effective_mode, ToolMode::Enabled); + assert!(!lease.lookup().requires_approval); + assert_eq!(lease.revisions().source_revision, 1); + assert_eq!(lease.revisions().catalog_revision, 1); + assert_eq!(lease.revisions().tool_revision, 0); + assert_eq!(lease.revisions().binding_revision, 0); + assert_eq!(lease.revisions().credential_revision, Some(0)); + assert_eq!(lease.input_schema()["additionalProperties"], false); + assert!(lease.arguments_are_valid(&json!({ "query": {} }))); + assert!(!lease.arguments_are_valid(&json!({ "unknown": true }))); + assert_eq!( + lease.source_configuration()["displayUrl"], + "https://weather.example.test/openapi.json" + ); + assert_eq!( + lease + .credential() + .expect("credential snapshot should exist") + .credential + .payload["credentials"]["schemes"], + json!({}) + ); + assert_eq!( + lease + .binding() + .openapi() + .expect("binding should be OpenAPI") + .method, + "GET" + ); +} + +#[tokio::test] +async fn invocation_lease_blocks_mutation_and_old_token_revalidation_fails_after_release() { + let (_directory, app, _source_id, tool_id) = imported_source().await; + let lease = app + .catalog() + .prepare_invocation("tools.weather.get_weather") + .await + .expect("invocation should prepare"); + let token = lease.revisions().clone(); + let catalog = app.catalog().clone(); + let mutation = tokio::spawn(async move { + catalog + .set_tool_mode( + &tool_id, + Some(ToolMode::Ask), + token.tool_revision, + AuditContext::system(Some("mode-during-invocation")), + ) + .await + }); + + tokio::task::yield_now().await; + assert!(!mutation.is_finished()); + let token = lease.revisions().clone(); + drop(lease); + let changed = tokio::time::timeout(std::time::Duration::from_secs(2), mutation) + .await + .expect("writer should resume after lease release") + .expect("writer task should not panic") + .expect("mode change should succeed"); + assert_eq!(changed.effective_mode.mode, ToolMode::Ask); + + assert!( + app.catalog() + .revalidate_invocation(&token) + .await + .expect("revalidation should read") + .is_none() + ); + let fresh = app + .catalog() + .prepare_invocation("weather.get_weather") + .await + .expect("fresh invocation should prepare"); + assert!(fresh.lookup().requires_approval); + assert!(fresh.revisions().tool_revision > token.tool_revision); + assert!(fresh.revisions().source_revision > token.source_revision); +} diff --git a/tests/openapi_api.rs b/tests/openapi_api.rs new file mode 100644 index 000000000..6942d9645 --- /dev/null +++ b/tests/openapi_api.rs @@ -0,0 +1,788 @@ +use std::net::SocketAddr; + +use axum::{ + Json, Router, + body::Body, + http::{Method, Request, StatusCode, header}, + routing::get, +}; +use executor::catalog::{AuditContext, CreateSource, SourceKind, ToolMode}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct Admin { + cookie: String, + csrf: String, +} + +#[tokio::test] +async fn source_import_rejects_an_unsupported_openapi_31_schema_dialect_without_writes() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://example.test/custom-dialect", + "info": { "title": "Unsupported dialect", "version": "1" }, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "responses": { "200": { "description": "ok" } } + } + } + } + }); + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Unsupported dialect", + "preferredSlug": "unsupported-dialect", + "spec": { "type": "inline", "content": specification.to_string() } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(response).await["error"]["code"], + "invalid_openapi_document" + ); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources should list") + .is_empty() + ); +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn send_raw( + router: Router, + method: Method, + uri: &str, + raw_body: String, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(raw_body)) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should have a value") + }) + .collect::>() + .join("; ") +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh instance should have a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": "correct horse battery staple" }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +#[tokio::test] +async fn preview_import_and_invoke_enforce_planes_modes_and_body_limits() { + let upstream = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = upstream.local_addr().expect("upstream address should read"); + let upstream_router = Router::new().route( + "/api/hello", + get(|| async { Json(json!({ "message": "hello" })) }), + ); + let upstream_task = tokio::spawn(async move { + axum::serve(upstream, upstream_router) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let padding = "x".repeat(20_000); + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Local API", "description": padding }, + "servers": [{ "url": format!("http://{address}/api") }], + "security": [{ "ApiKey": [] }, {}], + "components": { + "securitySchemes": { + "ApiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + } + }, + "paths": { + "/hello": { + "get": { + "operationId": "hello", + "parameters": [{ + "name": "X-Padding", + "in": "header", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "ok", "content": { "application/json": { "schema": { "type": "object" } } } } } + } + }, + "/write": { + "post": { + "operationId": "write", + "responses": { "204": { "description": "ok" } } + } + } + } + }) + .to_string(); + + let unauthenticated = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ "spec": { "type": "inline", "content": specification } }), + &[], + ) + .await; + assert_eq!(unauthenticated.status(), StatusCode::FORBIDDEN); + + let preview = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { "type": "inline", "content": specification }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(preview.status(), StatusCode::OK); + let preview = body(preview).await; + assert_eq!(preview["toolCount"], 2); + assert_eq!(preview["securitySchemes"][0]["name"], "ApiKey"); + assert_eq!(preview["securitySchemes"][0]["credentialType"], "api_key"); + assert_eq!(preview["tools"][0]["security"][0], json!(["ApiKey"])); + + let missing_inline_server = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { + "type": "inline", + "content": json!({ + "openapi": "3.1.0", + "info": { "title": "No server" }, + "paths": { "/x": { "get": { "responses": {} } } } + }).to_string() + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(missing_inline_server.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(missing_inline_server).await["error"]["code"], + "inline_openapi_server_required" + ); + let missing_server_create = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "No server", + "spec": { + "type": "inline", + "content": json!({ + "openapi": "3.1.0", + "info": { "title": "No server" }, + "paths": { "/x": { "get": { "responses": {} } } } + }).to_string() + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(missing_server_create.status(), StatusCode::BAD_REQUEST); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(app.pool()) + .await + .expect("source count should read"), + 0 + ); + + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Local API", + "preferredSlug": "local", + "spec": { "type": "inline", "content": specification }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + + let before_refresh = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list"); + let write_tool = before_refresh + .items + .iter() + .find(|tool| tool.local_name == "write") + .expect("write tool should exist"); + let stable_write_id = write_tool.id.clone(); + app.catalog() + .set_tool_mode( + &stable_write_id, + Some(ToolMode::Ask), + write_tool.revision, + AuditContext::system(Some("test-mode-override")), + ) + .await + .expect("tool override should store"); + let source_id = app + .catalog() + .list_sources() + .await + .expect("sources should list")[0] + .id + .clone(); + let refreshed = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(&admin), + ) + .await; + assert_eq!(refreshed.status(), StatusCode::OK); + let after_refresh = app + .catalog() + .list_tools(Default::default()) + .await + .expect("refreshed tools should list"); + let write_tool = after_refresh + .items + .iter() + .find(|tool| tool.local_name == "write") + .expect("write tool should survive refresh"); + assert_eq!(write_tool.id, stable_write_id); + assert_eq!(write_tool.mode_override, Some(ToolMode::Ask)); + + let token_response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "test" }), + &admin_headers(&admin), + ) + .await; + assert_eq!(token_response.status(), StatusCode::CREATED); + let token = body(token_response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned(); + let authorization = format!("Bearer {token}"); + + let invoked = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "local.hello", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invoked.status(), StatusCode::OK); + let invoked = body(invoked).await; + assert_eq!(invoked["ok"], true); + assert_eq!(invoked["data"]["message"], "hello"); + + let large_but_allowed = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ + "path": "local.hello", + "arguments": { "headers": { "X-Padding": "x".repeat(20_000) } } + }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(large_but_allowed.status(), StatusCode::OK); + + let over_invoke_cap = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ + "path": "local.hello", + "arguments": { "padding": "x".repeat(8 * 1024 * 1024 + 128 * 1024) } + }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(over_invoke_cap.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let over_import_cap = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { "type": "inline", "content": "x".repeat(16 * 1024 * 1024 + 128 * 1024) } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(over_import_cap.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let over_create_cap = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Too large", + "spec": { "type": "inline", "content": "x".repeat(16 * 1024 * 1024 + 128 * 1024) } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(over_create_cap.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let invalid = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "local.hello", "arguments": [] }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(invalid).await["error"]["code"], + "invalid_tool_arguments" + ); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let logged = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs WHERE error_code = 'invalid_tool_arguments'", + ) + .fetch_one(app.pool()) + .await + .expect("request logs should read"); + if logged == 1 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("failed invocation should be logged"); + + let ask = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "local.write", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(ask.status(), StatusCode::CONFLICT); + assert_eq!(body(ask).await["error"]["code"], "approval_required"); + + upstream_task.abort(); +} + +#[tokio::test] +async fn source_configuration_never_exposes_a_query_bearing_spec_url() { + let spec_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("spec listener should bind"); + let address: SocketAddr = spec_listener + .local_addr() + .expect("spec address should read"); + let server_url = format!("http://{address}"); + let spec = json!({ + "openapi": "3.0.3", + "info": { "title": "URL API" }, + "servers": [{ "url": server_url }], + "paths": {} + }); + let spec_router = Router::new().route( + "/openapi.json", + get(move || { + let spec = spec.clone(); + async move { Json(spec) } + }), + ); + let spec_task = tokio::spawn(async move { + axum::serve(spec_listener, spec_router) + .await + .expect("spec server should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let secret_url = format!("http://{address}/openapi.json?api_key=plaintext-secret"); + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "URL API", + "spec": { "type": "url", "url": secret_url }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let created = body(created).await; + assert!(!created.to_string().contains("plaintext-secret")); + let source_id = created["id"] + .as_str() + .expect("created source should have an ID"); + let metadata = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/credentials"), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(metadata.status(), StatusCode::OK); + let metadata = body(metadata).await; + assert_eq!(metadata["revision"], 0); + assert_eq!(metadata["configuredSchemes"], json!([])); + + let static_secret = "static-secret-never-returned"; + let updated = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { + "schemes": { + "ApiKey": { "type": "api_key", "value": static_secret } + } + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(updated.status(), StatusCode::OK); + let updated = body(updated).await; + assert_eq!(updated["revision"], 1); + assert_eq!( + updated["configuredSchemes"], + json!([{ "name": "ApiKey", "credentialType": "api_key" }]) + ); + assert!(!updated.to_string().contains(static_secret)); + + let configuration = sqlx::query_scalar::<_, String>("SELECT configuration_json FROM sources") + .fetch_one(app.pool()) + .await + .expect("source config should read"); + assert!(!configuration.contains("plaintext-secret")); + let ciphertext = + sqlx::query_scalar::<_, Vec>("SELECT payload_ciphertext FROM source_credentials") + .fetch_one(app.pool()) + .await + .expect("credential ciphertext should read"); + assert!(!String::from_utf8_lossy(&ciphertext).contains("plaintext-secret")); + assert!(!String::from_utf8_lossy(&ciphertext).contains(static_secret)); + + spec_task.abort(); +} + +#[tokio::test] +async fn large_body_routes_authenticate_before_parsing_json() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let malformed = format!("{{{}", "x".repeat(64 * 1024)); + + for uri in ["/api/v1/sources", "/api/v1/sources/openapi/preview"] { + let response = send_raw(app.router(), Method::POST, uri, malformed.clone(), &[]).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body(response).await["error"]["code"], "invalid_origin"); + } + let response = send_raw( + app.router(), + Method::PUT, + "/api/v1/sources/not-a-source/credentials", + malformed.clone(), + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body(response).await["error"]["code"], "invalid_origin"); + + let response = send_raw( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + malformed, + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body(response).await["error"]["code"], "unauthorized"); +} + +#[tokio::test] +async fn openapi_credentials_reject_other_protocols_and_unknown_schema_versions() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let graphql = app + .catalog() + .create_source( + CreateSource { + kind: SourceKind::Graphql, + preferred_slug: "graphql".to_owned(), + display_name: "GraphQL".to_owned(), + description: None, + configuration: serde_json::Map::new(), + }, + AuditContext::system(Some("protocol-credential-contract")), + ) + .await + .expect("GraphQL source should create"); + let response = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{}/credentials", graphql.id), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(response).await["error"]["code"], + "unsupported_source_kind" + ); + let response = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{}/credentials", graphql.id), + json!({ "expectedRevision": 0, "credential": {} }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(response).await["error"]["code"], + "unsupported_source_kind" + ); + + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Credential schema" }, + "servers": [{ "url": "https://example.com" }], + "paths": { + "/hello": { + "get": { + "operationId": "hello", + "responses": { "200": { "description": "ok" } } + } + } + } + }); + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Credential schema", + "preferredSlug": "schema", + "spec": { "type": "inline", "content": specification.to_string() } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let source_id = body(response).await["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let mut credential = app + .catalog() + .credential(&source_id) + .await + .expect("credential should read") + .expect("credential should exist"); + credential.credential.schema_version = 2; + app.catalog() + .put_credential( + &source_id, + &credential.credential, + Some(credential.revision), + AuditContext::system(Some("unsupported-credential-schema")), + ) + .await + .expect("future credential schema should store opaquely"); + + let response = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/credentials"), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!( + body(response).await["error"]["code"], + "unsupported_credential_schema" + ); + + let token_response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "credential-schema" }), + &admin_headers(&admin), + ) + .await; + let token = body(token_response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned(); + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "schema.hello", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(response).await["error"]["code"], + "unsupported_credential_schema" + ); +} diff --git a/tests/openapi_atomic_create.rs b/tests/openapi_atomic_create.rs new file mode 100644 index 000000000..838a17599 --- /dev/null +++ b/tests/openapi_atomic_create.rs @@ -0,0 +1,198 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CatalogError, CreateSource, CredentialPayload, + InitialCatalogSnapshot, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, + ToolBinding, ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +fn source_input() -> CreateSource { + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "Weather API".to_owned(), + display_name: "Weather API".to_owned(), + description: Some("Forecast operations".to_owned()), + configuration: Map::from_iter([( + "displayUrl".to_owned(), + json!("https://weather.example.test/openapi.json"), + )]), + } +} + +fn credential() -> CredentialPayload { + CredentialPayload { + schema_version: 1, + payload: json!({ + "schemes": { + "weatherKey": { + "type": "api_key", + "value": "atomic-secret" + } + } + }), + } +} + +fn snapshot() -> InitialCatalogSnapshot { + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "get:/weather".to_owned(), + preferred_name: "getWeather".to_owned(), + display_name: "Get weather".to_owned(), + description: Some("Read a forecast".to_owned()), + input_schema: json!({ "type": "object" }), + output_schema: Some(json!({ "type": "object" })), + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Enabled, + }], + } +} + +fn bindings() -> Vec { + vec![StagedToolBinding { + stable_key: "get:/weather".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: "/weather".to_owned(), + server_url: "https://weather.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }] +} + +async fn row_count(app: &ExecutorApp, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(app.pool()) + .await + .expect("row count should read") +} + +#[tokio::test] +async fn imported_source_creation_commits_every_catalog_record_or_nothing() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "CREATE TRIGGER reject_atomic_binding BEFORE INSERT ON tool_bindings \ + BEGIN SELECT RAISE(ABORT, 'reject atomic binding'); END", + ) + .execute(app.pool()) + .await + .expect("failure trigger should install"); + + app.catalog() + .create_source_with_catalog( + source_input(), + &credential(), + snapshot(), + bindings(), + AuditContext::system(Some("atomic-failure")), + ) + .await + .expect_err("a binding write failure should abort creation"); + + for table in [ + "sources", + "source_credentials", + "source_artifacts", + "tools", + "tool_bindings", + "tool_search", + "tool_search_trigram", + "tool_search_short", + "audit_events", + ] { + assert_eq!(row_count(&app, table).await, 0, "{table} must roll back"); + } + assert_eq!(app.catalog().global_revision().await.unwrap(), 0); + + sqlx::query("DROP TRIGGER reject_atomic_binding") + .execute(app.pool()) + .await + .expect("failure trigger should drop"); + let (source, sync) = app + .catalog() + .create_source_with_catalog( + source_input(), + &credential(), + snapshot(), + bindings(), + AuditContext::system(Some("atomic-success")), + ) + .await + .expect("atomic creation should succeed"); + + assert_eq!(source.slug, "weather_api"); + assert_eq!(source.revision, 1); + assert_eq!(source.catalog_revision, 1); + assert_eq!(source.tool_count, 1); + assert_eq!(sync.source_id, source.id); + assert_eq!(sync.global_revision, 1); + assert_eq!(sync.active_tool_count, 1); + assert_eq!( + app.catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist") + .credential + .payload["schemes"]["weatherKey"]["value"], + "atomic-secret" + ); + assert_eq!(row_count(&app, "source_artifacts").await, 1); + assert_eq!(row_count(&app, "tool_bindings").await, 1); + assert_eq!(row_count(&app, "tool_search").await, 1); + assert_eq!(row_count(&app, "tool_search_trigram").await, 1); + assert_eq!(row_count(&app, "tool_search_short").await, 1); + assert_eq!(row_count(&app, "audit_events").await, 3); +} + +#[tokio::test] +async fn malformed_input_schema_is_rejected_before_atomic_creation_writes() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let mut invalid_snapshot = snapshot(); + invalid_snapshot.tools[0].input_schema = json!({ "type": "not-a-json-schema-type" }); + + let error = app + .catalog() + .create_source_with_catalog( + source_input(), + &credential(), + invalid_snapshot, + bindings(), + AuditContext::system(Some("invalid-schema")), + ) + .await + .expect_err("invalid schema should fail before catalog creation"); + assert!(matches!( + error, + CatalogError::Validation { + code: "invalid_tool_input_schema", + .. + } + )); + assert_eq!(row_count(&app, "sources").await, 0); + assert_eq!(row_count(&app, "tools").await, 0); + assert_eq!(app.catalog().global_revision().await.unwrap(), 0); +} diff --git a/tests/openapi_gateway_contract.rs b/tests/openapi_gateway_contract.rs new file mode 100644 index 000000000..deb0662fa --- /dev/null +++ b/tests/openapi_gateway_contract.rs @@ -0,0 +1,832 @@ +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header}, +}; +use executor::{ + AppConfig, ExecutorApp, + catalog::{AuditContext, ToolMode}, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tokio::{ + net::TcpListener, + sync::{Mutex, Notify}, +}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct Admin { + cookie: String, + csrf: String, +} + +#[derive(Clone, Default)] +struct Recorder { + count: Arc, + requests: Arc>>, +} + +impl Recorder { + fn count(&self) -> usize { + self.count.load(Ordering::SeqCst) + } + + async fn take_last(&self) -> Value { + self.requests + .lock() + .await + .last() + .cloned() + .expect("the upstream should have recorded a request") + } +} + +async fn record_upstream(State(recorder): State, request: Request) -> Json { + recorder.count.fetch_add(1, Ordering::SeqCst); + let method = request.method().to_string(); + let path = request.uri().path().to_owned(); + let query = request.uri().query().unwrap_or_default().to_owned(); + let headers = request + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), Value::String(value.to_owned()))) + }) + .collect::>(); + let body = request + .into_body() + .collect() + .await + .expect("upstream request body should collect") + .to_bytes(); + let observed = json!({ + "method": method, + "path": path, + "query": query, + "headers": headers, + "body": String::from_utf8_lossy(&body), + }); + recorder.requests.lock().await.push(observed.clone()); + Json(observed) +} + +#[derive(Clone, Default)] +struct BlockingUpstream { + entered: Arc, + release: Arc, +} + +async fn block_upstream(State(state): State) -> Json { + state.entered.notify_one(); + state.release.notified().await; + Json(json!({ "completed": true })) +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn response_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should have a value") + }) + .collect::>() + .join("; ") +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh instance should have a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": "correct horse battery staple" }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = response_body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +async fn create_gateway_token(app: &ExecutorApp, admin: &Admin) -> String { + let response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "OpenAPI contract" }), + &admin_headers(admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response_body(response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned() +} + +async fn invoke( + app: &ExecutorApp, + token: &str, + path: &str, + arguments: Value, +) -> (StatusCode, Value) { + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": path, "arguments": arguments }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + let status = response.status(); + (status, response_body(response).await) +} + +fn operation(method: &str, operation_id: &str, security: Value) -> Value { + json!({ + method: { + "operationId": operation_id, + "security": security, + "responses": { + "200": { + "description": "recorded", + "content": { + "application/json": { "schema": { "type": "object" } } + } + } + } + } + }) +} + +async fn set_mode(app: &ExecutorApp, local_name: &str, mode: ToolMode) { + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .into_iter() + .find(|tool| tool.local_name == local_name) + .unwrap_or_else(|| panic!("tool {local_name} should exist")); + app.catalog() + .set_tool_mode( + &tool.id, + Some(mode), + tool.revision, + AuditContext::system(Some("openapi-gateway-contract")), + ) + .await + .expect("tool mode should update"); +} + +async fn wait_for_log(app: &ExecutorApp, path: &str, outcome: &str, error_code: &str) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs \ + WHERE path_snapshot = ? AND outcome = ? AND error_code = ?", + ) + .bind(path) + .bind(outcome) + .bind(error_code) + .fetch_one(app.pool()) + .await + .expect("request logs should read"); + if count > 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("request log {path} {outcome} {error_code} should be written")); +} + +#[tokio::test] +async fn production_gateway_honors_openapi_security_arguments_bodies_and_modes() { + let recorder = Recorder::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream = Router::new() + .fallback(record_upstream) + .with_state(recorder.clone()); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + + let mut paths = Map::new(); + for (path, operation_id, security) in [ + ("/anonymous", "anonymous", json!([{}])), + ( + "/or-fallback", + "or_fallback", + json!([{ "MissingKey": [] }, {}]), + ), + ("/header", "header_key", json!([{ "HeaderKey": [] }])), + ("/query", "query_key", json!([{ "QueryKey": [] }])), + ("/cookie", "cookie_key", json!([{ "CookieKey": [] }])), + ("/bearer", "bearer", json!([{ "BearerAuth": [] }])), + ("/basic", "basic", json!([{ "BasicAuth": [] }])), + ("/oauth", "oauth", json!([{ "OAuth": ["read"] }])), + ("/no-body", "no_body", json!([{}])), + ("/disabled", "disabled", json!([{}])), + ] { + paths.insert(path.to_owned(), operation("get", operation_id, security)); + } + paths.insert( + "/combined/{id}".to_owned(), + json!({ + "get": { + "operationId": "combined", + "security": [{ "HeaderKey": [], "QueryKey": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "q", "in": "query", "schema": { "type": "string" } }, + { "name": "X-Input", "in": "header", "schema": { "type": "string" } }, + { "name": "flavor", "in": "cookie", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "recorded" } } + } + }), + ); + for (path, operation_id, content) in [ + ("/json", "json_body", "application/json"), + ("/text", "text_body", "text/plain"), + ("/form", "form_body", "application/x-www-form-urlencoded"), + ("/ask", "ask", "application/json"), + ] { + let schema = match content { + "application/x-www-form-urlencoded" => json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "a": { "type": "string" }, + "b": { "type": "integer" } + } + }), + "text/plain" => json!({ "type": "string" }), + _ => json!({ + "type": "object", + "additionalProperties": false, + "required": ["hello", "mode", "nested"], + "properties": { + "hello": { "type": "string", "pattern": "^[a-z]+$" }, + "count": { "type": "integer", "minimum": 1 }, + "mode": { "enum": ["safe", "fast"] }, + "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "nested": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { "id": { "type": "integer" } } + }, + "choice": { + "oneOf": [ + { "type": "string", "pattern": "^choice-" }, + { "type": "integer", "minimum": 1 } + ] + }, + "maybe": { "type": ["string", "null"] } + } + }), + }; + paths.insert( + path.to_owned(), + json!({ + "post": { + "operationId": operation_id, + "security": [{}], + "requestBody": { + "required": true, + "content": { content: { "schema": schema } } + }, + "responses": { "200": { "description": "recorded" } } + } + }), + ); + } + paths.insert( + "/multi".to_owned(), + json!({ + "post": { + "operationId": "multi_body", + "security": [{}], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["json"], + "properties": { "json": { "type": "boolean" } } + } + }, + "text/plain": { "schema": { "type": "string" } } + } + }, + "responses": { "200": { "description": "recorded" } } + } + }), + ); + for (path, operation_id, header_name) in [ + ( + "/method-override", + "method_override", + "X-HTTP-Method-Override", + ), + ("/http-method", "http_method", "X-HTTP-Method"), + ("/original-method", "original_method", "X-Original-Method"), + ("/forwarded", "forwarded", "X-Forwarded-For"), + ] { + paths.insert( + path.to_owned(), + json!({ + "get": { + "operationId": operation_id, + "security": [{}], + "parameters": [{ + "name": header_name, + "in": "header", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "recorded" } } + } + }), + ); + } + + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Gateway Contract" }, + "servers": [{ "url": format!("http://{address}") }], + "components": { + "securitySchemes": { + "MissingKey": { "type": "apiKey", "in": "header", "name": "X-Missing" }, + "HeaderKey": { "type": "apiKey", "in": "header", "name": "X-Header-Key" }, + "QueryKey": { "type": "apiKey", "in": "query", "name": "query_key" }, + "CookieKey": { "type": "apiKey", "in": "cookie", "name": "cookie_key" }, + "BearerAuth": { "type": "http", "scheme": "bearer" }, + "BasicAuth": { "type": "http", "scheme": "basic" }, + "OAuth": { + "type": "oauth2", + "flows": { + "clientCredentials": { + "tokenUrl": "https://identity.example.test/token", + "scopes": { "read": "Read access" } + } + } + } + } + }, + "paths": paths + }); + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Gateway Contract", + "preferredSlug": "contract", + "spec": { "type": "inline", "content": specification.to_string() }, + "allowPrivateNetwork": true, + "credential": { + "schemes": { + "HeaderKey": { "type": "api_key", "value": "header-secret" }, + "QueryKey": { "type": "api_key", "value": "query-secret" }, + "CookieKey": { "type": "api_key", "value": "cookie-secret" }, + "BearerAuth": { "type": "bearer", "token": "bearer-secret" }, + "BasicAuth": { "type": "basic", "username": "aladdin", "password": "open-sesame" }, + "OAuth": { "type": "oauth_access_token", "access_token": "oauth-secret" } + } + } + }), + &admin_headers(&admin), + ) + .await; + let created_status = created.status(); + let created_body = response_body(created).await; + assert_eq!(created_status, StatusCode::CREATED, "{created_body}"); + let token = create_gateway_token(&app, &admin).await; + for tool in ["json_body", "text_body", "form_body", "multi_body"] { + set_mode(&app, tool, ToolMode::Enabled).await; + } + set_mode(&app, "disabled", ToolMode::Disabled).await; + + for path in ["anonymous", "or_fallback"] { + let (status, response) = invoke(&app, &token, &format!("contract.{path}"), json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["ok"], true); + } + + let (status, response) = invoke( + &app, + &token, + "contract.combined", + json!({ + "path": { "id": "a/b" }, + "query": { "q": "hello world" }, + "headers": { "X-Input": "header input" }, + "cookies": { "flavor": "mint chip" } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let observed = &response["data"]; + assert_eq!(observed["path"], "/combined/a%2Fb"); + let query = observed["query"].as_str().expect("query should be text"); + assert!(query.contains("q=hello+world")); + assert!(query.contains("query_key=query-secret")); + assert_eq!(observed["headers"]["x-header-key"], "header-secret"); + assert_eq!(observed["headers"]["x-input"], "header input"); + let cookie = observed["headers"]["cookie"] + .as_str() + .expect("cookie should be text"); + assert!(cookie.contains("flavor=mint%20chip")); + + let expected_auth = BTreeMap::from([ + ("header_key", ("x-header-key", "header-secret")), + ("bearer", ("authorization", "Bearer bearer-secret")), + ( + "basic", + ("authorization", "Basic YWxhZGRpbjpvcGVuLXNlc2FtZQ=="), + ), + ("oauth", ("authorization", "Bearer oauth-secret")), + ]); + for (tool, (name, value)) in expected_auth { + let (status, response) = invoke(&app, &token, &format!("contract.{tool}"), json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["headers"][name], value); + } + let (status, response) = invoke(&app, &token, "contract.query_key", json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["query"], "query_key=query-secret"); + let (status, response) = invoke(&app, &token, "contract.cookie_key", json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + response["data"]["headers"]["cookie"], + "cookie_key=cookie-secret" + ); + + for (tool, content_type, body, expected_body) in [ + ( + "json_body", + "application/json", + json!({ "hello": "world", "mode": "safe", "nested": { "id": 1 } }), + r#"{"hello":"world","mode":"safe","nested":{"id":1}}"#, + ), + ( + "text_body", + "text/plain", + json!("plain words"), + "plain words", + ), + ( + "form_body", + "application/x-www-form-urlencoded", + json!({ "a": "one two", "b": 2 }), + "a=one+two&b=2", + ), + ] { + let (status, response) = invoke( + &app, + &token, + &format!("contract.{tool}"), + json!({ "contentType": content_type, "body": body }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["method"], "POST"); + assert_eq!(response["data"]["headers"]["content-type"], content_type); + assert_eq!(response["data"]["body"], expected_body); + } + let (status, response) = invoke( + &app, + &token, + "contract.multi_body", + json!({ "contentType": "text/plain", "body": "plain multi" }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["body"], "plain multi"); + + for (tool, arguments) in [ + ("anonymous", json!({ "surprise": true })), + ("no_body", json!({ "body": { "no": "body" } })), + ("no_body", json!({ "contentType": "application/json" })), + ( + "combined", + json!({ "path": { "id": "ok" }, "query": { "q": 42 } }), + ), + ( + "combined", + json!({ "path": { "id": "ok" }, "query": { "unknown": "value" } }), + ), + ( + "json_body", + json!({ + "contentType": "application/json", + "body": { + "hello": "world", "mode": "safe", "nested": { "id": 1 }, "unknown": true + } + }), + ), + ( + "json_body", + json!({ "body": { "hello": "WORLD", "mode": "safe", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "count": 1.5, "mode": "safe", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "other", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "mode": "safe", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "safe", "nested": {} } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "safe", "nested": { "id": 1 }, "choice": true } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "safe", "nested": { "id": 1 }, "tags": ["same", "same"] } }), + ), + ( + "multi_body", + json!({ "contentType": "application/json", "body": "valid text, invalid JSON body" }), + ), + ] { + let before = recorder.count(); + let (status, response) = invoke(&app, &token, &format!("contract.{tool}"), arguments).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(response["error"]["code"], "invalid_tool_arguments"); + assert_eq!(recorder.count(), before); + } + wait_for_log( + &app, + "tools.contract.json_body", + "failed", + "invalid_tool_arguments", + ) + .await; + for (tool, header_name) in [ + ("method_override", "X-HTTP-Method-Override"), + ("http_method", "X-HTTP-Method"), + ("original_method", "X-Original-Method"), + ("forwarded", "X-Forwarded-For"), + ] { + let before = recorder.count(); + let (status, response) = invoke( + &app, + &token, + &format!("contract.{tool}"), + json!({ "headers": { header_name: "DELETE" } }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(response["error"]["code"], "forbidden_tool_header"); + assert_eq!(recorder.count(), before); + } + + let before = recorder.count(); + let (status, response) = invoke( + &app, + &token, + "contract.ask", + json!({ "body": { "hello": "write", "mode": "safe", "nested": { "id": 1 } } }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(response["error"]["code"], "approval_required"); + assert_eq!(recorder.count(), before); + wait_for_log( + &app, + "tools.contract.ask", + "pending_approval", + "approval_required", + ) + .await; + + let (status, response) = invoke(&app, &token, "contract.disabled", json!({})).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(response["error"]["code"], "tool_disabled"); + assert_eq!(recorder.count(), before); + wait_for_log(&app, "tools.contract.disabled", "denied", "tool_disabled").await; + + let last = recorder.take_last().await; + assert_eq!(last["path"], "/multi"); + upstream_task.abort(); +} + +#[tokio::test] +async fn invocation_holds_its_catalog_lease_until_the_upstream_request_finishes() { + let blocker = BlockingUpstream::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream = Router::new() + .fallback(block_upstream) + .with_state(blocker.clone()); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Lease Contract" }, + "servers": [{ "url": format!("http://{address}") }], + "paths": { + "/wait": { + "get": { + "operationId": "wait", + "security": [{}], + "responses": { "200": { "description": "completed" } } + } + } + } + }); + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Lease Contract", + "preferredSlug": "lease", + "spec": { "type": "inline", "content": specification.to_string() }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let token = create_gateway_token(&app, &admin).await; + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .into_iter() + .find(|tool| tool.local_name == "wait") + .expect("wait tool should exist"); + + let invoke_router = app.router(); + let invoke_token = token.clone(); + let invocation = tokio::spawn(async move { + let authorization = format!("Bearer {invoke_token}"); + let response = send( + invoke_router, + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "lease.wait", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + let status = response.status(); + (status, response_body(response).await) + }); + blocker.entered.notified().await; + + let catalog = app.catalog().clone(); + let tool_id = tool.id.clone(); + let mut mutation = tokio::spawn(async move { + catalog + .set_tool_mode( + &tool_id, + Some(ToolMode::Disabled), + tool.revision, + AuditContext::system(Some("lease-concurrency-regression")), + ) + .await + }); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut mutation) + .await + .is_err(), + "catalog mutation must wait while outbound invocation owns its lease" + ); + + blocker.release.notify_one(); + let (status, response) = invocation.await.expect("invocation task should complete"); + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["completed"], true); + tokio::time::timeout(Duration::from_secs(2), mutation) + .await + .expect("catalog mutation should resume after invocation") + .expect("catalog mutation task should complete") + .expect("catalog mutation should succeed"); + upstream_task.abort(); +} diff --git a/tests/openapi_lifecycle.rs b/tests/openapi_lifecycle.rs new file mode 100644 index 000000000..8ef685499 --- /dev/null +++ b/tests/openapi_lifecycle.rs @@ -0,0 +1,636 @@ +use std::{path::Path, sync::Arc}; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header}, + routing::get, +}; +use executor::{ + AppConfig, ExecutorApp, + catalog::{AuditContext, ListToolsFilter, ToolMode}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::{net::TcpListener, sync::RwLock}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct Admin { + cookie: String, + csrf: String, +} + +struct Upstream { + address: std::net::SocketAddr, + specification: Arc>, + task: tokio::task::JoinHandle<()>, +} + +impl Upstream { + async fn start() -> Self { + async fn serve_specification( + State(specification): State>>, + ) -> Json { + Json(specification.read().await.clone()) + } + + let specification = Arc::new(RwLock::new(json!({}))); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let router = Router::new() + .route("/openapi.json", get(serve_specification)) + .route( + "/api/hello", + get(|| async { Json(json!({ "message": "still callable" })) }), + ) + .with_state(Arc::clone(&specification)); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("upstream should serve"); + }); + Self { + address, + specification, + task, + } + } + + async fn set_paths(&self, paths: Value, description: &str) { + *self.specification.write().await = json!({ + "openapi": "3.1.0", + "info": { "title": "Lifecycle API", "description": description }, + "servers": [{ "url": format!("http://{}/api", self.address) }], + "paths": paths + }); + } + + fn spec_url(&self, query: Option<&str>) -> String { + match query { + Some(query) => format!("http://{}/openapi.json?{query}", self.address), + None => format!("http://{}/openapi.json", self.address), + } + } +} + +impl Drop for Upstream { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn json_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh app should expose a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = json_body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +async fn import_source( + app: &ExecutorApp, + admin: &Admin, + spec_url: &str, + preferred_slug: &str, + credential: Value, +) -> Value { + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Lifecycle API", + "preferredSlug": preferred_slug, + "spec": { "type": "url", "url": spec_url }, + "allowPrivateNetwork": true, + "credential": credential + }), + &admin_headers(admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await +} + +async fn gateway_token(app: &ExecutorApp, admin: &Admin) -> String { + let response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "lifecycle-test" }), + &admin_headers(admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await["token"] + .as_str() + .expect("token should be returned once") + .to_owned() +} + +async fn refresh(app: &ExecutorApp, admin: &Admin, source_id: &str) -> StatusCode { + send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(admin), + ) + .await + .status() +} + +fn hello_paths() -> Value { + json!({ + "/hello": { + "get": { + "operationId": "sayHello", + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { "schema": { "type": "object" } } + } + } + } + } + } + }) +} + +#[tokio::test] +async fn removed_then_restored_openapi_tool_keeps_identity_override_and_callable_binding() { + let upstream = Upstream::start().await; + upstream.set_paths(hello_paths(), "initial").await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "lifecycle", + json!({}), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let original = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .pop() + .expect("imported tool should exist"); + let original_binding = app + .catalog() + .tool_binding(&original.id) + .await + .expect("imported tool should have a binding"); + app.catalog() + .set_tool_mode( + &original.id, + Some(ToolMode::Enabled), + original.revision, + AuditContext::system(Some("lifecycle-mode")), + ) + .await + .expect("explicit tool mode should store"); + + upstream.set_paths(json!({}), "temporarily removed").await; + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + let tombstone = app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + ..Default::default() + }) + .await + .expect("tool history should list") + .items + .pop() + .expect("removed tool should remain in history"); + assert_eq!(tombstone.id, original.id); + assert_eq!(tombstone.local_name, original.local_name); + assert_eq!(tombstone.mode_override, Some(ToolMode::Enabled)); + assert!(!tombstone.present); + assert!(app.catalog().tool_binding(&original.id).await.is_err()); + + upstream.set_paths(hello_paths(), "restored").await; + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + let restored = app + .catalog() + .list_tools(Default::default()) + .await + .expect("restored tools should list") + .items + .pop() + .expect("tool should be restored"); + assert_eq!(restored.id, original.id); + assert_eq!(restored.local_name, original.local_name); + assert_eq!(restored.callable_path, original.callable_path); + assert_eq!(restored.mode_override, Some(ToolMode::Enabled)); + assert!(restored.present); + let restored_binding = app + .catalog() + .tool_binding(&restored.id) + .await + .expect("restored tool should regain a binding"); + assert_eq!(restored_binding.binding, original_binding.binding); + + let token = gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + let invoked = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": restored.sandbox_path, "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invoked.status(), StatusCode::OK); + assert_eq!( + json_body(invoked).await["data"]["message"], + "still callable" + ); +} + +#[tokio::test] +async fn refresh_binding_failure_rolls_back_artifact_tools_bindings_and_revisions() { + let upstream = Upstream::start().await; + upstream + .set_paths(hello_paths(), "before failed refresh") + .await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "rollback", + json!({}), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let before_source = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let before_global_revision = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let before_tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .pop() + .expect("tool should exist"); + let before_binding = app + .catalog() + .tool_binding(&before_tool.id) + .await + .expect("binding should exist"); + let before_artifact = sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source_id) + .fetch_one(app.pool()) + .await + .expect("artifact should read"); + let before_audits = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM audit_events") + .fetch_one(app.pool()) + .await + .expect("audit count should read"); + + upstream + .set_paths( + json!({ + "/hello": hello_paths()["/hello"].clone(), + "/new": { + "get": { + "operationId": "newOperation", + "responses": { "204": { "description": "ok" } } + } + } + }), + "must roll back", + ) + .await; + sqlx::query( + "CREATE TRIGGER reject_refresh_binding BEFORE INSERT ON tool_bindings \ + BEGIN SELECT RAISE(ABORT, 'reject refresh binding'); END", + ) + .execute(app.pool()) + .await + .expect("binding failure trigger should install"); + assert_eq!( + refresh(&app, &admin, &source_id).await, + StatusCode::INTERNAL_SERVER_ERROR + ); + + let after_source = app + .catalog() + .source(&source_id) + .await + .expect("source should still read"); + assert_eq!(after_source.revision, before_source.revision); + assert_eq!( + after_source.catalog_revision, + before_source.catalog_revision + ); + assert_eq!(after_source.tool_count, before_source.tool_count); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should still read"), + before_global_revision + ); + let after_tools = app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + ..Default::default() + }) + .await + .expect("tool history should still list"); + assert_eq!(after_tools.items.len(), 1); + assert_eq!(after_tools.items[0].id, before_tool.id); + assert_eq!(after_tools.items[0].revision, before_tool.revision); + assert_eq!( + app.catalog() + .tool_binding(&before_tool.id) + .await + .expect("old binding should survive") + .binding, + before_binding.binding + ); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source_id) + .fetch_one(app.pool()) + .await + .expect("artifact should survive"), + before_artifact + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM audit_events") + .fetch_one(app.pool()) + .await + .expect("audit count should still read"), + before_audits + ); + + let token = gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + let invoked = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": before_tool.sandbox_path, "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invoked.status(), StatusCode::OK); + assert_eq!( + json_body(invoked).await["data"]["message"], + "still callable" + ); +} + +#[tokio::test] +async fn credentials_delete_uses_cas_preserves_locator_and_leaves_no_plaintext_on_disk() { + let upstream = Upstream::start().await; + upstream + .set_paths( + json!({ + "/hello": { + "get": { + "operationId": "sayHello", + "security": [{ "ApiKey": [] }], + "responses": { "200": { "description": "ok" } } + } + } + }), + "secret persistence test", + ) + .await; + { + let mut specification = upstream.specification.write().await; + specification["components"] = json!({ + "securitySchemes": { + "ApiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + } + }); + } + let locator_secret = "locator-secret-4f08dd75512a47a6"; + let auth_secret = "auth-secret-a5477b04afe2405d"; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let data_dir = directory.path().to_path_buf(); + let app = ExecutorApp::open(AppConfig::new(data_dir.clone())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let secret_spec_url = upstream.spec_url(Some(&format!("api_key={locator_secret}"))); + let created = import_source( + &app, + &admin, + &secret_spec_url, + "secrets", + json!({ + "schemes": { + "ApiKey": { "type": "api_key", "value": auth_secret } + } + }), + ) + .await; + assert!(!created.to_string().contains(locator_secret)); + assert!(!created.to_string().contains(auth_secret)); + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + + let stale = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=7"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(stale.status(), StatusCode::CONFLICT); + let stale_body = json_body(stale).await; + assert_eq!(stale_body["error"]["code"], "revision_conflict"); + assert!(!stale_body.to_string().contains(locator_secret)); + assert!(!stale_body.to_string().contains(auth_secret)); + + let deleted = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=0"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(deleted.status(), StatusCode::OK); + let deleted_body = json_body(deleted).await; + assert_eq!(deleted_body["revision"], 1); + assert_eq!(deleted_body["configuredSchemes"], json!([])); + assert!(!deleted_body.to_string().contains(locator_secret)); + assert!(!deleted_body.to_string().contains(auth_secret)); + + let stored = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("locator envelope should remain"); + assert_eq!(stored.revision, 1); + assert_eq!(stored.credential.payload["locator"]["url"], secret_spec_url); + assert_eq!( + stored.credential.payload["credentials"]["schemes"], + json!({}) + ); + assert!(!stored.credential.payload.to_string().contains(auth_secret)); + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .fetch_all(app.pool()) + .await + .expect("WAL should checkpoint"); + app.pool().close().await; + drop(app); + + for file_name in ["executor.db", "executor.db-wal", "executor.db-shm"] { + let path = data_dir.join(file_name); + if path.exists() { + assert_file_excludes(&path, locator_secret); + assert_file_excludes(&path, auth_secret); + } + } +} + +fn assert_file_excludes(path: &Path, secret: &str) { + let bytes = std::fs::read(path).expect("database sidecar should read"); + assert!( + !bytes + .windows(secret.len()) + .any(|window| window == secret.as_bytes()), + "{} contains plaintext secret", + path.display() + ); +} diff --git a/tests/openapi_parser.rs b/tests/openapi_parser.rs new file mode 100644 index 000000000..0ef29407f --- /dev/null +++ b/tests/openapi_parser.rs @@ -0,0 +1,1502 @@ +use executor::{ + catalog::ToolMode, + openapi::{ + OpenApiCredential, OpenApiCredentialError, OpenApiCredentialSet, OpenApiError, + OpenApiInvocationError, OpenApiParameterLocation, OpenApiSecurityScheme, + build_protocol_request, build_protocol_request_with_base, compile_document, + }, +}; +use serde_json::json; +use url::Url; + +type StaticCredentialSet = OpenApiCredentialSet; +type StaticCredentialScheme = OpenApiCredential; + +#[test] +fn compiles_yaml_with_parameter_server_media_and_security_precedence() { + let document = br#" +openapi: 3.1.0 +info: + title: Pet Service + description: Pet operations +servers: + - url: https://root.example.test/{version} + variables: + version: + default: v1 +security: + - rootKey: [] +paths: + /pets/{pet_id}: + servers: + - url: https://path.example.test + parameters: + - $ref: '#/components/parameters/PetId' + - name: view + in: query + schema: + type: string + get: + operationId: getPet + summary: Get a pet + servers: + - url: https://operation.example.test + parameters: + - name: view + in: query + required: true + schema: + enum: [summary, full] + security: + - bearerAuth: [] + rootKey: [] + - {} + responses: + '200': + description: ok + content: + application/problem+json: + schema: + type: object + application/json: + schema: + $ref: '#/components/schemas/Pet' + /pets: + post: + operationId: createPet + requestBody: + required: true + content: + text/plain: + schema: { type: string } + application/json: + schema: + $ref: '#/components/schemas/PetInput' + responses: + default: + description: response +components: + parameters: + PetId: + name: pet_id + in: path + required: true + schema: { type: string } + schemas: + Pet: + type: object + properties: + id: { type: string } + PetInput: + type: object + properties: + name: { type: string } + required: [name] + securitySchemes: + rootKey: + type: apiKey + in: header + name: X-Root-Key + bearerAuth: + type: http + scheme: bearer + oauth: + type: oauth2 + flows: {} +"#; + let compiled = compile_document(document).expect("valid YAML should compile"); + assert_eq!(compiled.title, "Pet Service"); + assert_eq!(compiled.tools.len(), 2); + + let get = compiled + .tools + .iter() + .find(|tool| tool.preferred_name == "getPet") + .expect("GET operation should exist"); + assert_eq!(get.intrinsic_mode, ToolMode::Enabled); + assert_eq!(get.binding.server_url, "https://operation.example.test"); + assert_eq!(get.binding.parameters.len(), 2); + let view = get + .binding + .parameters + .iter() + .find(|parameter| parameter.name == "view") + .expect("operation query parameter should replace the path parameter"); + assert!(view.required); + assert_eq!(view.location, OpenApiParameterLocation::Query); + assert_eq!(get.binding.security.len(), 2); + assert_eq!(get.binding.security[0].requirements.len(), 2); + assert!(get.binding.security[1].requirements.is_empty()); + assert!(matches!( + get.binding.security[0].requirements[0].scheme, + OpenApiSecurityScheme::Http { .. } + )); + assert_eq!( + get.output_schema, + Some(json!({ + "type": "object", + "properties": { "id": { "type": "string" } } + })) + ); + + let post = compiled + .tools + .iter() + .find(|tool| tool.preferred_name == "createPet") + .expect("POST operation should exist"); + assert_eq!(post.intrinsic_mode, ToolMode::Ask); + let body = post + .binding + .request_body + .as_ref() + .expect("request body should compile"); + assert_eq!(body.default_media_type, "application/json"); + assert_eq!( + body.media_types, + vec!["application/json".to_owned(), "text/plain".to_owned()] + ); + assert_eq!( + post.binding.security[0].requirements[0].scheme_name, + "rootKey" + ); +} + +#[test] +fn openapi_30_input_schemas_normalize_nullable_and_exclusive_bounds_to_2020_12() { + let compiled = compile_document( + serde_json::to_string(&json!({ + "openapi": "3.0.3", + "info": { "title": "OpenAPI 3.0 schema normalization", "version": "1" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "parameters": [{ + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "nullable": true, + "minimum": 5, + "exclusiveMinimum": true + } + }], + "responses": { "200": { "description": "ok" } } + } + } + } + })) + .expect("specification should serialize") + .as_bytes(), + ) + .expect("OpenAPI 3.0 specification should compile"); + let schema = &compiled.tools[0].input_schema; + assert_eq!( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + let limit = &schema["properties"]["query"]["properties"]["limit"]; + let types = limit["type"] + .as_array() + .expect("nullable schema should become a type union"); + assert_eq!(types, &[json!("integer"), json!("null")]); + assert_eq!(limit["exclusiveMinimum"], 5); + assert!(limit.get("minimum").is_none()); +} + +#[test] +fn openapi_30_rejects_reference_siblings_instead_of_overriding_constraints() { + let error = compile_document( + serde_json::to_string(&json!({ + "openapi": "3.0.3", + "info": { "title": "Reference sibling", "version": "1" }, + "paths": { + "/items": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Input", + "additionalProperties": true + } + } + } + }, + "responses": { "200": { "description": "ok" } } + } + } + }, + "components": { + "schemas": { + "Input": { + "type": "object", + "additionalProperties": false, + "properties": { "name": { "type": "string" } } + } + } + } + })) + .expect("specification should serialize") + .as_bytes(), + ) + .expect_err("OpenAPI 3.0 reference siblings should be rejected"); + assert!(matches!(error, OpenApiError::InvalidDocument(_))); +} + +#[test] +fn request_projection_does_not_require_read_only_properties() { + let compiled = compile_document( + serde_json::to_string(&json!({ + "openapi": "3.1.0", + "info": { "title": "Read-only request projection", "version": "1" }, + "paths": { + "/items": { + "post": { + "operationId": "createItem", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "readOnly": true }, + "name": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "ok" } } + } + } + } + })) + .expect("specification should serialize") + .as_bytes(), + ) + .expect("OpenAPI specification should compile"); + assert_eq!( + compiled.tools[0].input_schema["properties"]["body"]["required"], + json!(["name"]) + ); +} + +#[test] +fn openapi_31_accepts_only_implemented_json_schema_dialects() { + let make_document = |dialect: Option<&str>| { + let mut document = json!({ + "openapi": "3.1.0", + "info": { "title": "Dialect", "version": "1" }, + "paths": { + "/items": { + "get": { + "responses": { "200": { "description": "ok" } } + } + } + } + }); + if let Some(dialect) = dialect { + document["jsonSchemaDialect"] = json!(dialect); + } + document + }; + + for dialect in [ + None, + Some("https://spec.openapis.org/oas/3.1/dialect/base"), + Some("https://json-schema.org/draft/2020-12/schema"), + Some("https://json-schema.org/draft/2020-12/schema#"), + ] { + let document = make_document(dialect); + compile_document(&serde_json::to_vec(&document).unwrap_or_default()).unwrap_or_else( + |error| panic!("supported dialect {dialect:?} should compile: {error}"), + ); + } + + for dialect in [ + "https://example.test/custom-dialect", + "https://json-schema.org/draft/2019-09/schema", + ] { + let document = make_document(Some(dialect)); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "the OpenAPI 3.1 JSON Schema dialect is not supported" + )) + )); + } + + let mut non_string = make_document(None); + non_string["jsonSchemaDialect"] = json!({ "uri": "not a string" }); + assert!(matches!( + compile_document(&serde_json::to_vec(&non_string).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "jsonSchemaDialect must be a URI string" + )) + )); + + let mut schema_override = make_document(None); + schema_override["paths"]["/items"]["get"]["parameters"] = json!([{ + "name": "query", + "in": "query", + "schema": { + "$schema": "https://example.test/custom-dialect", + "type": "string", + "x-custom-assertion": true + } + }]); + assert!(matches!( + compile_document(&serde_json::to_vec(&schema_override).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "a Schema Object uses an unsupported JSON Schema dialect" + )) + )); + + let mut nested_override = make_document(None); + nested_override["paths"]["/items"]["get"]["parameters"] = json!([{ + "name": "query", + "in": "query", + "schema": { + "type": "array", + "unevaluatedItems": { + "$schema": "https://example.test/custom-dialect", + "x-custom-assertion": true + } + } + }]); + assert!(matches!( + compile_document(&serde_json::to_vec(&nested_override).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "a Schema Object uses an unsupported JSON Schema dialect" + )) + )); +} + +#[test] +fn local_pointer_decoding_and_ref_siblings_are_supported() { + let document = json!({ + "openapi": "3.1.0", + "info": { "title": "Refs" }, + "paths": { + "/things": { + "get": { + "parameters": [{ + "name": "filter", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Encoded%20Name", + "description": "override" + } + }], + "responses": { "204": { "description": "none" } } + } + } + }, + "components": { + "schemas": { + "Encoded Name": { "type": "string" } + } + } + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + assert_eq!( + compiled.tools[0].input_schema["properties"]["query"]["properties"]["filter"], + json!({ "type": "string", "description": "override" }) + ); +} + +#[test] +fn rejects_swagger_external_refs_missing_refs_and_cycles() { + let swagger = br#"{"swagger":"2.0","info":{"title":"old"},"paths":{}}"#; + assert!(matches!( + compile_document(swagger), + Err(OpenApiError::UnsupportedVersion) + )); + + let external = br#"{ + "openapi":"3.0.3","info":{"title":"external"}, + "paths":{"/x":{"get":{"responses":{"200":{"$ref":"https://example.test/r"}}}}} + }"#; + assert!(matches!( + compile_document(external), + Err(OpenApiError::ExternalReference(_)) + )); + + let missing = br##"{ + "openapi":"3.0.3","info":{"title":"missing"}, + "paths":{"/x":{"get":{"parameters":[{"$ref":"#/components/parameters/nope"}],"responses":{}}}} + }"##; + assert!(matches!( + compile_document(missing), + Err(OpenApiError::ReferenceNotFound(_)) + )); + + let cycle = br##"{ + "openapi":"3.0.3","info":{"title":"cycle"}, + "paths":{"/x":{"get":{"parameters":[{"$ref":"#/components/parameters/a"}],"responses":{}}}}, + "components":{"parameters":{ + "a":{"$ref":"#/components/parameters/b"}, + "b":{"$ref":"#/components/parameters/a"} + }} + }"##; + assert!(matches!( + compile_document(cycle), + Err(OpenApiError::ReferenceCycle(_)) + )); + + let duplicate_yaml = br#" +openapi: 3.0.3 +info: { title: Duplicate } +paths: {} +paths: {} +"#; + assert!(matches!( + compile_document(duplicate_yaml), + Err(OpenApiError::Parse) + )); +} + +#[test] +fn rejects_path_parameters_that_do_not_match_the_template() { + let document = br#"{ + "openapi":"3.0.3","info":{"title":"paths"}, + "paths":{"/items/{id}":{"get":{"responses":{"200":{"description":"ok"}}}}} + }"#; + assert!(matches!( + compile_document(document), + Err(OpenApiError::InvalidOperation { .. }) + )); +} + +#[test] +fn stable_identity_does_not_depend_on_operation_id_and_trace_is_disabled() { + let make = |operation_id: &str| { + json!({ + "openapi": "3.0.3", + "info": { "title": "Identity" }, + "paths": { + "/items/{id}": { + "parameters": [{ + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }], + "trace": { + "operationId": operation_id, + "responses": { "200": { "description": "ok" } } + } + } + } + }) + }; + let first = compile_document(&serde_json::to_vec(&make("firstName")).unwrap()).unwrap(); + let second = compile_document(&serde_json::to_vec(&make("secondName")).unwrap()).unwrap(); + assert_eq!(first.tools[0].stable_key, second.tools[0].stable_key); + assert_ne!( + first.tools[0].preferred_name, + second.tools[0].preferred_name + ); + assert_eq!(first.tools[0].intrinsic_mode, ToolMode::Disabled); +} + +#[test] +fn request_credentials_override_user_carriers() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Invoke" }, + "servers": [{ "url": "https://api.example.test/v1" }], + "components": { "securitySchemes": { + "key": { "type": "apiKey", "in": "query", "name": "api_key" }, + "bearer": { "type": "http", "scheme": "bearer" } + }}, + "paths": { "/items/{id}": { "post": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "api_key", "in": "query", "schema": { "type": "string" } }, + { "name": "Api_Key", "in": "query", "schema": { "type": "string" } }, + { "name": "tags", "in": "query", "explode": true, "schema": { "type": "array" } }, + { "name": "X-Trace", "in": "header", "schema": { "type": "string" } } + ], + "security": [{ "key": [], "bearer": [] }], + "requestBody": { "required": true, "content": { + "application/json": { "schema": { "type": "object" } } + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = StaticCredentialSet { + schemes: [ + ( + "key".to_owned(), + StaticCredentialScheme::ApiKey { + value: "secret-key".to_owned(), + }, + ), + ( + "bearer".to_owned(), + StaticCredentialScheme::Bearer { + token: "secret-token".to_owned(), + }, + ), + ] + .into_iter() + .collect(), + }; + let request = build_protocol_request( + &compiled.tools[0].binding, + &json!({ + "path": { "id": "a/b" }, + "query": { "api_key": "attacker", "Api_Key": "case-sensitive", "tags": ["one", "two"] }, + "headers": { "X-Trace": "trace-1" }, + "body": { "name": "item" } + }), + &credentials, + ) + .unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.url.path(), "/v1/items/a%2Fb"); + let query = request.url.query_pairs().collect::>(); + assert_eq!( + query + .iter() + .filter(|(name, _)| name == "api_key") + .map(|(_, value)| value.as_ref()) + .collect::>(), + vec!["secret-key"] + ); + assert_eq!( + query + .iter() + .filter(|(name, _)| name == "Api_Key") + .map(|(_, value)| value.as_ref()) + .collect::>(), + vec!["case-sensitive"] + ); + assert_eq!( + query + .iter() + .filter(|(name, _)| name == "tags") + .map(|(_, value)| value.as_ref()) + .collect::>(), + vec!["one", "two"] + ); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some("Bearer secret-token") + ); + assert_eq!( + request.headers.get("x-trace").map(String::as_str), + Some("trace-1") + ); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some("application/json") + ); + assert_eq!(request.body, br#"{"name":"item"}"#); +} + +#[test] +fn request_construction_rejects_protected_headers_and_crlf_credentials() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Safety" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "key": { "type": "apiKey", "in": "header", "name": "X-Api-Key" } + }}, + "paths": { "/safe": { "get": { + "parameters": [{ "name": "Authorization", "in": "header", "schema": { "type": "string" } }], + "security": [{ "key": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = |value: &str| StaticCredentialSet { + schemes: [( + "key".to_owned(), + StaticCredentialScheme::ApiKey { + value: value.to_owned(), + }, + )] + .into_iter() + .collect(), + }; + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "headers": { "Authorization": "user" } }), + &credentials("secret") + ), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); + assert!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({}), + &credentials("secret\r\nX-Evil: yes") + ) + .is_err() + ); +} + +#[test] +fn tiny_exponential_reference_dag_hits_the_global_resolution_budget() { + let mut schemas = serde_json::Map::new(); + schemas.insert("Node0".to_owned(), json!({ "type": "string" })); + for index in 1..=18 { + let reference = format!("#/components/schemas/Node{}", index - 1); + schemas.insert( + format!("Node{index}"), + json!([{ "$ref": reference }, { "$ref": reference }]), + ); + } + let document = json!({ + "openapi": "3.1.0", + "info": { "title": "Expansion budget" }, + "components": { "schemas": schemas }, + "paths": { "/expand": { "get": { + "parameters": [{ + "name": "value", "in": "query", + "schema": { "$ref": "#/components/schemas/Node18" } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let result = compile_document(&serde_json::to_vec(&document).unwrap()); + assert!( + matches!( + &result, + Err(OpenApiError::LimitExceeded { + code: "resolved_nodes" + }) + ), + "unexpected expansion result: {result:?}" + ); +} + +#[test] +fn rejects_parameter_serializations_the_invoker_cannot_execute() { + let cases = [ + ("path", "label", false), + ("header", "form", false), + ("query", "matrix", false), + ("cookie", "simple", false), + ("query", "form", true), + ]; + for (location, style, allow_reserved) in cases { + let path = if location == "path" { + "/items/{value}" + } else { + "/items" + }; + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsupported parameter" }, + "paths": { path: { "get": { + "parameters": [{ + "name": "value", "in": location, + "required": location == "path", "style": style, + "allowReserved": allow_reserved, + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } +} + +#[test] +fn rejects_content_based_parameters_the_invoker_cannot_serialize() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Content parameter" }, + "paths": { "/items": { "get": { + "parameters": [{ + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); +} + +#[test] +fn rejects_request_body_media_the_invoker_cannot_encode() { + for media_type in ["multipart/form-data", "application/octet-stream"] { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsupported body" }, + "paths": { "/upload": { "post": { + "requestBody": { "content": { + media_type: { "schema": { "type": "object" } } + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } +} + +#[test] +fn request_body_schemas_match_the_production_encoders() { + let cases = [ + ("text/plain", json!({ "type": "object", "properties": {} })), + ( + "application/x-www-form-urlencoded", + json!({ + "type": "object", + "properties": { "tags": { "type": "array" } }, + "additionalProperties": false + }), + ), + ( + "application/x-www-form-urlencoded", + json!({ "type": "object", "additionalProperties": true }), + ), + ( + "application/x-www-form-urlencoded", + json!({ + "type": "object", + "properties": { "name": { "type": "string" } } + }), + ), + ]; + for (media_type, schema) in cases { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Body schema" }, + "paths": { "/submit": { "post": { + "requestBody": { "content": { media_type: { "schema": schema } } }, + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } + + let valid_form = json!({ + "openapi": "3.0.3", + "info": { "title": "Form body" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/submit": { "post": { + "requestBody": { "content": { + "application/x-www-form-urlencoded": { "schema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + }} + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&valid_form).unwrap()).unwrap(); + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "body": { "name": { "nested": true } } }), + &StaticCredentialSet::default() + ), + Err(OpenApiInvocationError::InvalidArgument(_)) + )); +} + +#[test] +fn parameterized_json_is_preferred_over_text_request_media() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Media preference" }, + "paths": { "/submit": { "post": { + "requestBody": { "content": { + "text/plain": { "schema": { "type": "string" } }, + "application/json; charset=utf-8": { "schema": { "type": "object" } } + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + assert_eq!( + compiled.tools[0] + .binding + .request_body + .as_ref() + .map(|body| body.default_media_type.as_str()), + Some("application/json; charset=utf-8") + ); +} + +#[test] +fn rejects_server_urls_with_plaintext_secret_or_ambient_components() { + for server_url in [ + "https://user:password@api.example.test/v1", + "https://api.example.test/v1?api_key=secret", + "https://api.example.test/v1#fragment", + "ftp://api.example.test/v1", + "//user:password@api.example.test/v1", + ] { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsafe server" }, + "servers": [{ "url": server_url }], + "paths": { "/items": { "get": { + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } + + for server_url in ["https://api.example.test/v1", "/relative/v1"] { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Safe server" }, + "servers": [{ "url": server_url }], + "paths": { "/items": { "get": { + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(compile_document(&serde_json::to_vec(&document).unwrap()).is_ok()); + } + + let hidden_in_fallback = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsafe fallback" }, + "servers": [ + { "url": "https://api.example.test" }, + { "url": "https://api.example.test?secret=value" } + ], + "paths": {} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&hidden_in_fallback).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); +} + +#[test] +fn rejects_and_security_requirements_that_overwrite_the_same_carrier() { + let document = |security: serde_json::Value| { + json!({ + "openapi": "3.0.3", + "info": { "title": "Carrier conflict" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "basic": { "type": "http", "scheme": "basic" }, + "bearer": { "type": "http", "scheme": "bearer" } + }}, + "paths": { "/items": { "get": { + "security": security, + "responses": { "200": { "description": "ok" } } + }}} + }) + }; + let conflicting = document(json!([{ "basic": [], "bearer": [] }])); + assert!(matches!( + compile_document(&serde_json::to_vec(&conflicting).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + + let alternatives = document(json!([{ "basic": [] }, { "bearer": [] }])); + let compiled = compile_document(&serde_json::to_vec(&alternatives).unwrap()).unwrap(); + let mut binding = compiled.tools[0].binding.clone(); + let bearer = binding.security[1].requirements[0].clone(); + binding.security[0].requirements.push(bearer); + binding.security.truncate(1); + let credentials = StaticCredentialSet { + schemes: [ + ( + "basic".to_owned(), + StaticCredentialScheme::Basic { + username: "user".to_owned(), + password: "password".to_owned(), + }, + ), + ( + "bearer".to_owned(), + StaticCredentialScheme::Bearer { + token: "token".to_owned(), + }, + ), + ] + .into_iter() + .collect(), + }; + assert!(matches!( + build_protocol_request(&binding, &json!({}), &credentials), + Err(OpenApiInvocationError::UnsatisfiedSecurity) + )); +} + +#[test] +fn public_request_builder_rejects_cookie_name_injection() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Cookie safety" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "cookie": { "type": "apiKey", "in": "cookie", "name": "session\r\nX-Evil" } + }}, + "paths": { "/items": { "get": { + "parameters": [{ + "name": "user;admin=true", "in": "cookie", "schema": { "type": "string" } + }], + "security": [{ "cookie": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = StaticCredentialSet { + schemes: [( + "cookie".to_owned(), + StaticCredentialScheme::ApiKey { + value: "secret".to_owned(), + }, + )] + .into_iter() + .collect(), + }; + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "cookies": { "user;admin=true": "yes" } }), + &credentials + ), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); + + let without_parameter = json!({ + "openapi": "3.0.3", + "info": { "title": "Credential cookie safety" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "cookie": { "type": "apiKey", "in": "cookie", "name": "session\r\nX-Evil" } + }}, + "paths": { "/items": { "get": { + "security": [{ "cookie": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&without_parameter).unwrap()).unwrap(); + assert!(matches!( + build_protocol_request(&compiled.tools[0].binding, &json!({}), &credentials), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); +} + +#[test] +fn rejects_authentication_schemes_the_invoker_cannot_apply() { + for scheme in [ + json!({ "type": "http", "scheme": "digest" }), + json!({ "type": "mutualTLS" }), + ] { + let document = json!({ + "openapi": "3.1.0", + "info": { "title": "Unsupported authentication" }, + "components": { "securitySchemes": { "unsupported": scheme } }, + "paths": { "/items": { "get": { + "security": [{ "unsupported": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } +} + +#[test] +fn public_request_builder_rejects_unknown_argument_members() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Strict arguments" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/items": { "get": { + "parameters": [{ + "name": "known", "in": "query", "schema": { "type": "string" } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + for arguments in [ + json!({ "unknown": true }), + json!({ "query": { "unknown": "value" } }), + json!({ "query": "not-an-object" }), + json!({ "body": { "unexpected": true } }), + json!({ "contentType": "application/json" }), + ] { + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &arguments, + &StaticCredentialSet::default() + ), + Err(OpenApiInvocationError::InvalidArgument(_)) + )); + } +} + +#[test] +fn public_request_builder_rejects_identity_and_rewrite_headers() { + let names = [ + "X-HTTP-Method-Override", + "X-HTTP-Method", + "X-Method-Override", + "X-Original-Method", + "X-Real-IP", + "X-Original-URL", + "X-Rewrite-URL", + "X-Original-Host", + "X-Forwarded-Custom", + ]; + let parameters = names + .iter() + .map(|name| json!({ "name": name, "in": "header", "schema": { "type": "string" } })) + .collect::>(); + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Protected headers" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/items": { "post": { + "parameters": parameters, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + for name in names { + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "headers": { (name): "spoofed" } }), + &StaticCredentialSet::default() + ), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); + } +} + +#[test] +fn canonical_credentials_validate_and_round_trip_every_supported_type() { + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + OpenApiCredentialSet::default() + ); + let credentials: OpenApiCredentialSet = serde_json::from_value(json!({ + "schemes": { + "headerKey": { "type": "api_key", "value": "key" }, + "bearer": { "type": "bearer", "token": "bearer-token" }, + "basic": { "type": "basic", "username": "user", "password": "password" }, + "oauth": { "type": "oauth_access_token", "access_token": "oauth-token" } + } + })) + .expect("the canonical credential vocabulary should deserialize"); + credentials.validate().expect("credentials should validate"); + assert_eq!( + credentials + .schemes + .values() + .map(OpenApiCredential::credential_type) + .collect::>(), + vec!["basic", "bearer", "api_key", "manual_oauth_access_token"] + ); + assert_eq!( + serde_json::to_value(&credentials).unwrap(), + json!({ + "schemes": { + "basic": { "type": "basic", "username": "user", "password": "password" }, + "bearer": { "type": "bearer", "token": "bearer-token" }, + "headerKey": { "type": "api_key", "value": "key" }, + "oauth": { "type": "oauth_access_token", "access_token": "oauth-token" } + } + }) + ); + + let invalid = OpenApiCredentialSet { + schemes: [( + String::new(), + OpenApiCredential::Bearer { + token: String::new(), + }, + )] + .into_iter() + .collect(), + }; + assert_eq!( + invalid.validate(), + Err(OpenApiCredentialError::InvalidSchemeName) + ); +} + +#[test] +fn canonical_builder_applies_api_key_basic_bearer_and_oauth_credentials() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Authentication" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "headerKey": { "type": "apiKey", "in": "header", "name": "X-Api-Key" }, + "queryKey": { "type": "apiKey", "in": "query", "name": "api_key" }, + "cookieKey": { "type": "apiKey", "in": "cookie", "name": "session" }, + "basic": { "type": "http", "scheme": "basic" }, + "bearer": { "type": "http", "scheme": "bearer" }, + "oauth": { "type": "oauth2", "flows": {} } + }}, + "paths": { + "/keys": { "get": { + "security": [{ "headerKey": [], "queryKey": [], "cookieKey": [] }], + "responses": { "200": { "description": "ok" } } + }}, + "/basic": { "get": { + "security": [{ "basic": [] }], + "responses": { "200": { "description": "ok" } } + }}, + "/bearer": { "get": { + "security": [{ "bearer": [] }], + "responses": { "200": { "description": "ok" } } + }}, + "/oauth": { "get": { + "security": [{ "oauth": [] }], + "responses": { "200": { "description": "ok" } } + }} + } + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = OpenApiCredentialSet { + schemes: [ + ( + "headerKey", + OpenApiCredential::ApiKey { + value: "header-secret".to_owned(), + }, + ), + ( + "queryKey", + OpenApiCredential::ApiKey { + value: "query-secret".to_owned(), + }, + ), + ( + "cookieKey", + OpenApiCredential::ApiKey { + value: "cookie-secret".to_owned(), + }, + ), + ( + "basic", + OpenApiCredential::Basic { + username: "user".to_owned(), + password: "pass".to_owned(), + }, + ), + ( + "bearer", + OpenApiCredential::Bearer { + token: "bearer-secret".to_owned(), + }, + ), + ( + "oauth", + OpenApiCredential::OAuthAccessToken { + access_token: "oauth-secret".to_owned(), + }, + ), + ] + .into_iter() + .map(|(name, credential)| (name.to_owned(), credential)) + .collect(), + }; + let request = |path: &str| { + let binding = &compiled + .tools + .iter() + .find(|tool| tool.binding.path_template == path) + .unwrap() + .binding; + build_protocol_request(binding, &json!({}), &credentials).unwrap() + }; + + let keys = request("/keys"); + assert_eq!( + keys.headers.get("x-api-key").map(String::as_str), + Some("header-secret") + ); + assert_eq!( + keys.headers.get("cookie").map(String::as_str), + Some("session=cookie-secret") + ); + assert!( + keys.url + .query_pairs() + .any(|pair| pair == ("api_key".into(), "query-secret".into())) + ); + assert_eq!( + request("/basic").headers["authorization"], + "Basic dXNlcjpwYXNz" + ); + assert_eq!( + request("/bearer").headers["authorization"], + "Bearer bearer-secret" + ); + assert_eq!( + request("/oauth").headers["authorization"], + "Bearer oauth-secret" + ); +} + +#[test] +fn canonical_builder_honors_or_and_anonymous_security_semantics() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Security semantics" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "first": { "type": "apiKey", "in": "header", "name": "X-First" }, + "second": { "type": "apiKey", "in": "query", "name": "second" } + }}, + "paths": { + "/or": { "get": { "security": [{ "first": [] }, { "second": [] }], "responses": {} } }, + "/and": { "get": { "security": [{ "first": [], "second": [] }], "responses": {} } }, + "/anonymous": { "get": { "security": [{ "first": [] }, {}], "responses": {} } } + } + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let second_only = OpenApiCredentialSet { + schemes: [( + "second".to_owned(), + OpenApiCredential::ApiKey { + value: "two".to_owned(), + }, + )] + .into_iter() + .collect(), + }; + let binding = |path: &str| { + &compiled + .tools + .iter() + .find(|tool| tool.binding.path_template == path) + .unwrap() + .binding + }; + assert!(build_protocol_request(binding("/or"), &json!({}), &second_only).is_ok()); + assert!(matches!( + build_protocol_request(binding("/and"), &json!({}), &second_only), + Err(OpenApiInvocationError::UnsatisfiedSecurity) + )); + assert!( + build_protocol_request( + binding("/anonymous"), + &json!({}), + &OpenApiCredentialSet::default() + ) + .is_ok() + ); +} + +#[test] +fn canonical_builder_falls_through_an_unusable_or_security_alternative() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Security fallback" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "badHeader": { "type": "apiKey", "in": "header", "name": "Host" }, + "bearer": { "type": "http", "scheme": "bearer" } + }}, + "paths": { "/items": { "get": { + "security": [{ "badHeader": [] }, { "bearer": [] }], + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = OpenApiCredentialSet { + schemes: [ + ( + "badHeader".to_owned(), + OpenApiCredential::ApiKey { + value: "bad".to_owned(), + }, + ), + ( + "bearer".to_owned(), + OpenApiCredential::Bearer { + token: "good".to_owned(), + }, + ), + ] + .into_iter() + .collect(), + }; + let request = build_protocol_request(&compiled.tools[0].binding, &json!({}), &credentials) + .expect("the valid second OR alternative should be selected"); + assert_eq!(request.headers["authorization"], "Bearer good"); + assert!(!request.headers.contains_key("host")); +} + +#[test] +fn canonical_builder_serializes_cookie_form_explode_variants() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Cookies" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/cookies": { "get": { + "parameters": [ + { "name": "arrFalse", "in": "cookie", "explode": false, "schema": { "type": "array" } }, + { "name": "arrTrue", "in": "cookie", "explode": true, "schema": { "type": "array" } }, + { "name": "objectFalse", "in": "cookie", "explode": false, "schema": { "type": "object" } }, + { "name": "objectTrue", "in": "cookie", "explode": true, "schema": { "type": "object" } } + ], + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let request = build_protocol_request( + &compiled.tools[0].binding, + &json!({ "cookies": { + "arrFalse": ["one", "two"], + "arrTrue": ["one", "two"], + "objectFalse": { "a": 1, "b": 2 }, + "objectTrue": { "a": 1, "b": 2 } + }}), + &OpenApiCredentialSet::default(), + ) + .unwrap(); + assert_eq!( + request.headers["cookie"], + "arrFalse=one%2Ctwo; arrTrue=one&arrTrue=two; objectFalse=a%2C1%2Cb%2C2; a=1&b=2" + ); +} + +#[test] +fn canonical_builder_encodes_bodies_resolves_relative_servers_and_validates_methods() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Transport" }, + "servers": [{ "url": "../v2" }], + "paths": { "/submit": { "post": { + "requestBody": { "content": { + "application/json": { "schema": { "type": "object" } }, + "application/x-www-form-urlencoded": { "schema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + }}, + "text/plain": { "schema": { "type": "string" } } + }}, + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let binding = &compiled.tools[0].binding; + let base = Url::parse("https://api.example.test/specs/openapi.json").unwrap(); + let request = build_protocol_request_with_base( + binding, + &json!({ "body": { "name": "Ada" } }), + &OpenApiCredentialSet::default(), + Some(&base), + ) + .unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.url.as_str(), "https://api.example.test/v2/submit"); + assert_eq!(request.body, br#"{"name":"Ada"}"#); + + let form = build_protocol_request_with_base( + binding, + &json!({ "body": { "name": "Ada Lovelace" }, "contentType": "application/x-www-form-urlencoded" }), + &OpenApiCredentialSet::default(), + Some(&base), + ) + .unwrap(); + assert_eq!(form.body, b"name=Ada+Lovelace"); + let text = build_protocol_request_with_base( + binding, + &json!({ "body": "hello", "contentType": "text/plain" }), + &OpenApiCredentialSet::default(), + Some(&base), + ) + .unwrap(); + assert_eq!(text.body, b"hello"); + + let mut invalid = binding.clone(); + invalid.method = "POST\r\nX-Rewrite: yes".to_owned(); + assert!(matches!( + build_protocol_request_with_base( + &invalid, + &json!({}), + &OpenApiCredentialSet::default(), + Some(&base) + ), + Err(OpenApiInvocationError::InvalidArgument(argument)) if argument == "method" + )); +} + +#[test] +fn exact_success_response_precedes_2xx_wildcard_then_default() { + let compile = |include_exact: bool| { + let mut responses = serde_json::Map::from_iter([ + ( + "2XX".to_owned(), + json!({ + "description": "wildcard", + "content": { "application/json": { "schema": { "const": "wildcard" } } } + }), + ), + ( + "default".to_owned(), + json!({ + "description": "default", + "content": { "application/json": { "schema": { "const": "default" } } } + }), + ), + ]); + if include_exact { + responses.insert( + "201".to_owned(), + json!({ + "description": "exact", + "content": { "application/json": { "schema": { "const": "exact" } } } + }), + ); + } + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Response precedence" }, + "paths": { "/items": { "post": { "responses": responses } } } + }); + compile_document(&serde_json::to_vec(&document).unwrap()).unwrap() + }; + assert_eq!( + compile(false).tools[0].output_schema, + Some(json!({ "const": "wildcard" })) + ); + assert_eq!( + compile(true).tools[0].output_schema, + Some(json!({ "const": "exact" })) + ); +} diff --git a/tests/openapi_storage.rs b/tests/openapi_storage.rs new file mode 100644 index 000000000..3e9a34d35 --- /dev/null +++ b/tests/openapi_storage.rs @@ -0,0 +1,337 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, + ListToolsFilter, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, + ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +async fn app() -> (tempfile::TempDir, ExecutorApp) { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + (directory, app) +} + +fn staged_binding(method: &str) -> StagedToolBinding { + StagedToolBinding { + stable_key: "stable-get-weather".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: method.to_owned(), + path_template: "/weather".to_owned(), + server_url: "https://weather.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + } +} + +async fn source(app: &ExecutorApp) -> executor::catalog::SourceRecord { + app.catalog() + .create_source( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "weather".to_owned(), + display_name: "Weather".to_owned(), + description: None, + configuration: Map::new(), + }, + AuditContext::system(Some("test-create")), + ) + .await + .expect("source should be created") +} + +fn snapshot(source_revision: i64, credential_revision: Option) -> CatalogSnapshot { + CatalogSnapshot { + expected_source_revision: source_revision, + expected_credential_revision: credential_revision, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "stable-get-weather".to_owned(), + preferred_name: "get_weather".to_owned(), + display_name: "Get weather".to_owned(), + description: None, + input_schema: json!({ "type": "object" }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Enabled, + }], + } +} + +#[tokio::test] +async fn openapi_bindings_are_typed_complete_and_removed_with_the_source() { + let (_directory, app) = app().await; + let source = source(&app).await; + app.catalog() + .sync_catalog_with_bindings( + &source.id, + snapshot(source.revision, None), + vec![staged_binding("GET")], + AuditContext::system(Some("test-sync")), + ) + .await + .expect("catalog should sync"); + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .pop() + .expect("tool should exist"); + + let binding = app + .catalog() + .tool_binding(&tool.id) + .await + .expect("binding should read"); + assert_eq!( + binding.binding.openapi().expect("OpenAPI binding").method, + "GET" + ); + + let binding_before = sqlx::query_as::<_, (i64, i64, i64)>( + "SELECT revision, created_at, updated_at FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool.id) + .fetch_one(app.pool()) + .await + .expect("binding metadata should read"); + let refreshed_source = app + .catalog() + .source(&source.id) + .await + .expect("source should read"); + app.catalog() + .sync_catalog_with_bindings( + &source.id, + snapshot(refreshed_source.revision, None), + vec![staged_binding("POST")], + AuditContext::system(Some("test-refresh")), + ) + .await + .expect("catalog binding should refresh in place"); + let binding_after = sqlx::query_as::<_, (i64, i64, i64, String)>( + "SELECT revision, created_at, updated_at, definition_json FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool.id) + .fetch_one(app.pool()) + .await + .expect("refreshed binding metadata should read"); + assert_eq!(binding_after.0, binding_before.0 + 1); + assert_eq!(binding_after.1, binding_before.1); + assert!(binding_after.2 >= binding_before.2); + assert_eq!( + serde_json::from_str::(&binding_after.3) + .expect("binding should remain valid JSON")["method"], + "POST" + ); + + app.catalog() + .delete_source(&source.id, AuditContext::system(Some("test-delete"))) + .await + .expect("source should delete"); + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tool_bindings") + .fetch_one(app.pool()) + .await + .expect("binding count should read"); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn binding_write_failure_rolls_back_the_entire_catalog_refresh() { + let (_directory, app) = app().await; + let source = source(&app).await; + app.catalog() + .sync_catalog_with_bindings( + &source.id, + snapshot(source.revision, None), + vec![staged_binding("GET")], + AuditContext::system(Some("initial-sync")), + ) + .await + .expect("initial catalog should sync"); + let source_before = app + .catalog() + .source(&source.id) + .await + .expect("source should read"); + let global_revision_before = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let tool_before = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + let binding_before = sqlx::query_as::<_, (i64, i64, String)>( + "SELECT revision, created_at, definition_json FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool_before.id) + .fetch_one(app.pool()) + .await + .expect("binding should read"); + let artifact_before = sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source.id) + .fetch_one(app.pool()) + .await + .expect("artifact should read"); + sqlx::query( + "CREATE TRIGGER reject_binding_refresh BEFORE UPDATE ON tool_bindings \ + BEGIN SELECT RAISE(ABORT, 'binding write rejected'); END", + ) + .execute(app.pool()) + .await + .expect("failure trigger should install"); + + let mut replacement = snapshot(source_before.revision, None); + replacement.artifacts[0].content = json!({ "openapi": "3.1.1" }); + replacement.tools[0].display_name = "Changed weather".to_owned(); + let error = app + .catalog() + .sync_catalog_with_bindings( + &source.id, + replacement, + vec![staged_binding("POST")], + AuditContext::system(Some("failed-refresh")), + ) + .await + .expect_err("binding failure should abort refresh"); + assert!(matches!(error, CatalogError::Database(_))); + + let source_after = app + .catalog() + .source(&source.id) + .await + .expect("source should remain readable"); + assert_eq!(source_after.revision, source_before.revision); + assert_eq!( + source_after.catalog_revision, + source_before.catalog_revision + ); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should read"), + global_revision_before + ); + let tool_after = app + .catalog() + .tool(&tool_before.id) + .await + .expect("tool should remain readable"); + assert_eq!(tool_after.revision, tool_before.revision); + assert_eq!(tool_after.display_name, tool_before.display_name); + let binding_after = sqlx::query_as::<_, (i64, i64, String)>( + "SELECT revision, created_at, definition_json FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool_before.id) + .fetch_one(app.pool()) + .await + .expect("binding should remain readable"); + assert_eq!(binding_after, binding_before); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source.id) + .fetch_one(app.pool()) + .await + .expect("artifact should remain readable"), + artifact_before + ); +} + +#[tokio::test] +async fn credentials_delete_with_compare_and_swap_and_never_store_plaintext() { + let (_directory, app) = app().await; + let source = source(&app).await; + let secret = "never-store-this-api-key"; + app.catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "schemes": { "ApiKey": { "type": "api_key", "value": secret } } }), + }, + None, + AuditContext::system(Some("test-put-credential")), + ) + .await + .expect("credential should store"); + let stored = app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!( + stored.credential.payload["schemes"]["ApiKey"]["value"], + secret + ); + let raw = sqlx::query_scalar::<_, Vec>( + "SELECT payload_ciphertext FROM source_credentials WHERE source_id = ?", + ) + .bind(&source.id) + .fetch_one(app.pool()) + .await + .expect("ciphertext should read"); + assert!(!String::from_utf8_lossy(&raw).contains(secret)); + + let conflict = app + .catalog() + .delete_credential( + &source.id, + stored.revision + 1, + AuditContext::system(Some("test-delete-conflict")), + ) + .await; + assert!(matches!( + conflict, + Err(executor::catalog::CatalogError::RevisionConflict { .. }) + )); + app.catalog() + .delete_credential( + &source.id, + stored.revision, + AuditContext::system(Some("test-delete-credential")), + ) + .await + .expect("matching revision should delete"); + assert!( + app.catalog() + .credential(&source.id) + .await + .expect("credential state should read") + .is_none() + ); +} diff --git a/tests/outbound.rs b/tests/outbound.rs new file mode 100644 index 000000000..ab0e047aa --- /dev/null +++ b/tests/outbound.rs @@ -0,0 +1,266 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use executor::outbound::{ + AddressClass, HardenedHttpClient, OutboundError, OutboundPolicy, OutboundRequest, classify_ip, + parse_url, redirect_target, validate_url, +}; +use reqwest::{Method, header::HeaderValue}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; +use url::Url; + +async fn read_request(stream: &mut TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream + .read(&mut buffer) + .await + .expect("test request is readable"); + assert!(read > 0, "test request ended before its headers"); + request.extend_from_slice(&buffer[..read]); + } + String::from_utf8(request).expect("test request headers are UTF-8") +} + +#[test] +fn private_network_opt_in_never_allows_link_local_or_metadata_addresses() { + let denied = OutboundPolicy::default(); + let allowed = OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }; + + let private = Url::parse("http://10.20.30.40/spec.json").expect("private URL parses"); + assert!(matches!( + validate_url(&private, &denied), + Err(OutboundError::PrivateAddress) + )); + assert!(validate_url(&private, &allowed).is_ok()); + let tailscale = Url::parse("http://100.64.1.2/openapi.json").expect("tailnet URL parses"); + assert!(validate_url(&tailscale, &allowed).is_ok()); + + for target in [ + "http://169.254.169.254/latest/meta-data/", + "http://169.254.170.2/credentials", + "http://100.100.100.200/latest/meta-data/", + "http://[fe80::1]/metadata", + "http://[fd00:ec2::254]/latest/meta-data/", + "http://[fd20:ce::254]/computeMetadata/v1/", + "http://[fd00:c1::a9fe:a9fe]/opc/v2/", + ] { + let target = Url::parse(target).expect("forbidden URL parses"); + assert!(matches!( + validate_url(&target, &allowed), + Err(OutboundError::ForbiddenAddress) + )); + } +} + +#[test] +fn ipv4_mapped_ipv6_cannot_bypass_private_address_rules() { + let mapped = IpAddr::V6(Ipv6Addr::from_bits( + (0xffff_u128 << 32) | u32::from(Ipv4Addr::new(127, 0, 0, 1)) as u128, + )); + assert_eq!(classify_ip(mapped), AddressClass::Private); +} + +#[test] +fn special_use_and_encapsulated_addresses_are_not_public() { + let cases = [ + ("100.64.0.1", AddressClass::Private), + ("100.100.100.200", AddressClass::Forbidden), + ("192.0.2.1", AddressClass::Forbidden), + ("198.18.0.1", AddressClass::Forbidden), + ("203.0.113.1", AddressClass::Forbidden), + ("224.0.0.1", AddressClass::Forbidden), + ("2001:db8::1", AddressClass::Forbidden), + ("2001:5::1", AddressClass::Forbidden), + ("3fff:0fff:ffff::1", AddressClass::Forbidden), + ("3fff:1000::1", AddressClass::Public), + ("fc00::1", AddressClass::Private), + ("fd00:ec2::254", AddressClass::Forbidden), + ("fd20:ce::254", AddressClass::Forbidden), + ("fd00:c1::a9fe:a9fe", AddressClass::Forbidden), + ("2002:7f00:0001::", AddressClass::Private), + ("2606:4700:4700::1111", AddressClass::Public), + ("8.8.8.8", AddressClass::Public), + ]; + for (address, expected) in cases { + assert_eq!( + classify_ip(address.parse().expect("test address parses")), + expected, + "unexpected class for {address}" + ); + } +} + +#[test] +fn urls_reject_credential_fragments_and_non_http_schemes() { + let policy = OutboundPolicy::default(); + let cases = [ + ( + "https://user:secret@example.com/spec", + "outbound_credentials_not_allowed", + ), + ( + "https://example.com/spec#secret", + "outbound_fragment_not_allowed", + ), + ("file:///etc/passwd", "unsupported_outbound_scheme"), + ]; + for (url, expected_code) in cases { + let error = validate_url(&Url::parse(url).expect("test URL parses"), &policy) + .expect_err("unsafe URL is denied"); + assert_eq!(error.code(), expected_code); + } +} + +#[test] +fn malformed_url_returns_a_stable_public_error() { + let error = + parse_url("not a URL", &OutboundPolicy::default()).expect_err("malformed URL is rejected"); + assert_eq!(error.code(), "invalid_outbound_url"); + assert_eq!(error.to_string(), "the outbound URL is invalid"); +} + +#[test] +fn redirects_resolve_relative_locations_and_deny_downgrades() { + let policy = OutboundPolicy::default(); + let current = Url::parse("https://example.com/apis/openapi.json").expect("URL parses"); + let relative = HeaderValue::from_static("../v2/openapi.json?revision=2"); + let next = redirect_target(¤t, Some(&relative), &policy).expect("redirect is valid"); + assert_eq!( + next.as_str(), + "https://example.com/v2/openapi.json?revision=2" + ); + + let downgrade = HeaderValue::from_static("http://example.com/openapi.json"); + assert!(matches!( + redirect_target(¤t, Some(&downgrade), &policy), + Err(OutboundError::RedirectDowngrade) + )); +} + +#[test] +fn redirects_revalidate_literal_targets_at_every_hop() { + let policy = OutboundPolicy::default(); + let current = Url::parse("https://example.com/openapi.json").expect("URL parses"); + let metadata = HeaderValue::from_static("https://169.254.169.254/latest/meta-data/"); + assert!(matches!( + redirect_target(¤t, Some(&metadata), &policy), + Err(OutboundError::ForbiddenAddress) + )); +} + +#[tokio::test] +async fn cross_origin_redirects_drop_custom_credentials() { + let destination = TcpListener::bind("127.0.0.1:0") + .await + .expect("destination binds"); + let destination_address = destination + .local_addr() + .expect("destination has an address"); + let destination_task = tokio::spawn(async move { + let (mut stream, _) = destination.accept().await.expect("destination accepts"); + let request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .await + .expect("destination responds"); + request + }); + + let redirector = TcpListener::bind("127.0.0.1:0") + .await + .expect("redirector binds"); + let redirector_address = redirector.local_addr().expect("redirector has an address"); + let redirector_task = tokio::spawn(async move { + let (mut stream, _) = redirector.accept().await.expect("redirector accepts"); + let _request = read_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{destination_address}/final\r\nContent-Length: 0\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("redirector responds"); + }); + + let policy = OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-api-key", HeaderValue::from_static("super-secret")); + let response = client + .fetch_spec( + Url::parse(&format!("http://{redirector_address}/spec")).expect("redirect URL parses"), + headers, + ) + .await + .expect("redirected fetch succeeds"); + assert_eq!(response.body, b"{}"); + assert_eq!(response.final_url.path(), "/final"); + redirector_task.await.expect("redirector task completes"); + let destination_request = destination_task + .await + .expect("destination task completes") + .to_ascii_lowercase(); + assert!(!destination_request.contains("x-api-key")); + assert!(!destination_request.contains("super-secret")); +} + +#[tokio::test] +async fn response_size_cap_rejects_declared_oversize_before_returning_body() { + let server = TcpListener::bind("127.0.0.1:0") + .await + .expect("server binds"); + let address = server.local_addr().expect("server has an address"); + let server_task = tokio::spawn(async move { + let (mut stream, _) = server.accept().await.expect("server accepts"); + let _request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n12345") + .await + .expect("server responds"); + }); + let policy = OutboundPolicy { + allow_private_networks: true, + max_response_bytes: 4, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let request = OutboundRequest::new( + Method::GET, + Url::parse(&format!("http://{address}/oversize")).expect("request URL parses"), + ); + assert!(matches!( + client.execute(request).await, + Err(OutboundError::ResponseBodyTooLarge) + )); + server_task.await.expect("server task completes"); +} + +#[tokio::test] +async fn trace_and_connect_are_rejected_before_network_work() { + let policy = OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let url = Url::parse("http://127.0.0.1:9/").expect("request URL parses"); + for method in [Method::TRACE, Method::CONNECT] { + let error = match client + .execute(OutboundRequest::new(method, url.clone())) + .await + { + Err(error) => error, + Ok(_) => panic!("unsafe method is rejected"), + }; + assert_eq!(error.code(), "forbidden_outbound_method"); + } +} diff --git a/web/src/lib/DashboardShell.svelte b/web/src/lib/DashboardShell.svelte index 0709c3a57..2cf95f085 100644 --- a/web/src/lib/DashboardShell.svelte +++ b/web/src/lib/DashboardShell.svelte @@ -13,7 +13,7 @@ const navigation = [ { href: "/sources", label: "Sources", marker: "01" }, { href: "/tools", label: "Tools", marker: "02" }, - { href: "/approvals", label: "Approvals", marker: "03", count: "--" }, + { href: "/approvals", label: "Approvals", marker: "03" }, { href: "/logs", label: "Request logs", marker: "04" }, { href: "/tokens", label: "API tokens", marker: "05" }, ]; @@ -21,6 +21,10 @@ let logoutError = $state(null); let adminLabel = $derived(auth.username ?? "Administrator"); + function isActive(href: string) { + return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`); + } + async function logout() { logoutBusy = true; logoutError = null; @@ -53,26 +57,20 @@
-
Local instance - Connected over this origin + Self-hosted control plane
diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index f5cc72fd2..fe964975c 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -1,5 +1,85 @@ import { describe, expect, it } from "@effect/vitest"; -import { ApiError, createToken, getBootstrap, listTokens, loginAdmin } from "./api"; +import { Schema } from "effect"; +import { + ApiError, + bulkSetToolModes, + createOpenApiSource, + createToken, + deleteOpenApiCredentials, + getOpenApiCredentials, + getBootstrap, + listRequestLogs, + listSources, + listTokens, + listTools, + loginAdmin, + previewOpenApiSource, + putOpenApiCredentials, + refreshOpenApiSource, + setSourceMode, +} from "./api"; + +function sourceFixture() { + return { + id: "source-1", + kind: "openapi", + slug: "github", + displayName: "GitHub", + description: "Repository API", + configuration: { publicBaseUrl: "https://api.example.test" }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 3, + catalogRevision: 8, + createdAt: 100, + updatedAt: 200, + lastRefreshedAt: 190, + toolCount: 2, + tombstonedToolCount: 1, + }; +} + +function toolFixture() { + return { + id: "tool-1", + sourceId: "source-1", + sourceSlug: "github", + stableKey: "GET /repos", + localName: "list_repos", + callablePath: "tools.github.list_repos", + sandboxPath: "github.list_repos", + displayName: "List repositories", + description: "Lists repositories", + intrinsicMode: "enabled", + modeOverride: null, + effectiveMode: { mode: "enabled", provenance: "intrinsic" }, + present: true, + revision: 4, + createdAt: 100, + updatedAt: 200, + lastSeenAt: 190, + tombstonedAt: null, + }; +} + +function logFixture() { + return { + requestId: "request-1", + actorApiTokenId: "token-id", + surface: "gateway", + sourceId: "source-1", + toolId: "tool-1", + pathSnapshot: "tools.github.list_repos", + outcome: "succeeded", + errorCode: null, + durationMs: 27, + approvalId: null, + createdAt: 200, + }; +} + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); describe("dashboard API client", () => { it("normalizes bootstrap data from the control API", async () => { @@ -150,4 +230,267 @@ describe("dashboard API client", () => { }), }); }); + + it("strictly decodes source and tool catalog snapshots", async () => { + const sources = await listSources(async () => + Response.json({ sources: [sourceFixture()], catalogRevision: 12 }), + ); + const tools = await listTools( + { + query: "repos & teams", + sourceId: "source/one", + mode: "ask", + includeTombstoned: true, + limit: 50, + offset: 100, + }, + async (input) => { + expect(String(input)).toBe( + "/api/v1/tools?query=repos+%26+teams&sourceId=source%2Fone&mode=ask&includeTombstoned=true&limit=50&offset=100", + ); + return Response.json({ + items: [toolFixture()], + total: 1, + hasMore: false, + nextOffset: null, + catalogRevision: 12, + }); + }, + ); + + expect(sources.ok && sources.value.sources[0]?.kind).toBe("openapi"); + expect(tools.ok && tools.value.items[0]?.effectiveMode.provenance).toBe("intrinsic"); + }); + + it("rejects catalog enum drift instead of trusting response JSON", async () => { + const source = sourceFixture(); + const result = await listSources(async () => + Response.json( + { sources: [{ ...source, healthStatus: "mostly_fine" }], catalogRevision: 12 }, + { headers: { "x-request-id": "request-invalid-catalog" } }, + ), + ); + + expect(result).toEqual({ + ok: false, + error: new ApiError({ + code: "invalid_response", + displayMessage: "Executor returned a response the dashboard could not understand.", + requestId: "request-invalid-catalog", + status: 502, + }), + }); + }); + + it("sends source and current-page bulk revisions exactly once", async () => { + const bodies: unknown[] = []; + await setSourceMode("source/1", "ask", 7, async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/mode"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ ...sourceFixture(), modeOverride: "ask", revision: 8 }); + }); + await bulkSetToolModes(["tool-1", "tool-2"], "disabled", 19, async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + updatedCount: 2, + catalogRevision: 20, + sourceRevisions: { "source-1": 9 }, + }); + }); + + expect(bodies).toEqual([ + { mode: "ask", expectedRevision: 7 }, + { + selection: { + type: "tool_ids", + toolIds: ["tool-1", "tool-2"], + expectedCatalogRevision: 19, + }, + mode: "disabled", + }, + ]); + }); + + it("preserves revision conflicts for review instead of retrying", async () => { + const result = await setSourceMode("source-1", "enabled", 2, async () => + Response.json( + { + error: { + code: "revision_conflict", + message: "The source changed.", + requestId: "request-conflict", + }, + }, + { status: 409 }, + ), + ); + + expect(result).toEqual({ + ok: false, + error: new ApiError({ + code: "revision_conflict", + displayMessage: "The source changed.", + requestId: "request-conflict", + status: 409, + }), + }); + }); + + it("keeps request-log state metadata-only even if a server adds secret fields", async () => { + const secret = "secret-sentinel-never-render"; + const result = await listRequestLogs(null, async (input) => { + expect(String(input)).toBe("/api/v1/request-logs?limit=50"); + return Response.json({ + items: [ + { + ...logFixture(), + requestBody: secret, + responseBody: secret, + headers: { authorization: secret }, + credential: secret, + token: secret, + }, + ], + nextCursor: "older", + }); + }); + + expect(result.ok).toBe(true); + expect(JSON.stringify(result)).not.toContain(secret); + expect(result.ok && Object.keys(result.value.items[0] ?? {})).toEqual([ + "requestId", + "actorApiTokenId", + "surface", + "sourceId", + "toolId", + "pathSnapshot", + "outcome", + "errorCode", + "durationMs", + "approvalId", + "createdAt", + ]); + }); + + it("previews an OpenAPI URL with strict tool metadata", async () => { + let body: unknown; + const result = await previewOpenApiSource( + { type: "url", url: "https://api.example.test/openapi.json" }, + false, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/openapi/preview"); + body = decodeJson(String(init?.body)); + return Response.json({ + title: "Example API", + description: null, + toolCount: 1, + tools: [ + { + preferredName: "list_widgets", + displayName: "List widgets", + description: "Lists widgets", + intrinsicMode: "enabled", + security: [["bearerAuth"]], + }, + ], + securitySchemes: [ + { + name: "bearerAuth", + credentialType: "bearer", + placement: "header", + supported: true, + oauthFlows: null, + }, + ], + }); + }, + ); + + expect(body).toEqual({ + spec: { type: "url", url: "https://api.example.test/openapi.json" }, + allowPrivateNetwork: false, + }); + expect(result.ok && result.value.tools[0]?.intrinsicMode).toBe("enabled"); + expect(result.ok && result.value.securitySchemes[0]?.name).toBe("bearerAuth"); + }); + + it("imports credentials without accepting secret echoes in the source response", async () => { + const secret = "source-secret-sentinel"; + let body: unknown; + const result = await createOpenApiSource( + { + kind: "openapi", + displayName: "Example API", + spec: { type: "inline", content: "openapi: 3.1.0" }, + credential: { + schemes: { bearerAuth: { type: "bearer", token: secret } }, + }, + }, + async (_input, init) => { + body = decodeJson(String(init?.body)); + return Response.json({ ...sourceFixture(), credentials: secret }, { status: 201 }); + }, + ); + + expect(JSON.stringify(body)).toContain(secret); + expect(result.ok).toBe(true); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("decodes OpenAPI refresh counts", async () => { + const result = await refreshOpenApiSource("source/1", async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/refresh"); + expect(init?.body).toBe("{}"); + return Response.json({ + sourceId: "source/1", + sourceRevision: 4, + catalogRevision: 9, + globalRevision: 13, + activeToolCount: 6, + tombstonedToolCount: 2, + }); + }); + + expect(result.ok && result.value.activeToolCount).toBe(6); + }); + + it("reads and replaces only credential metadata and sends CAS revisions", async () => { + const metadata = { + revision: 4, + configuredSchemes: [{ name: "bearerAuth", credentialType: "bearer" }], + }; + const read = await getOpenApiCredentials("source-1", async () => Response.json(metadata)); + let putBody: unknown; + const replaced = await putOpenApiCredentials( + "source-1", + 4, + { oauth: { type: "oauth_access_token", access_token: "secret-token" } }, + async (_input, init) => { + putBody = decodeJson(String(init?.body)); + return Response.json({ + revision: 5, + configuredSchemes: [{ name: "oauth", credentialType: "manual_oauth_access_token" }], + }); + }, + ); + + expect(read).toEqual({ ok: true, value: metadata }); + expect(putBody).toEqual({ + expectedRevision: 4, + credential: { + schemes: { oauth: { type: "oauth_access_token", access_token: "secret-token" } }, + }, + }); + expect(replaced.ok && replaced.value.revision).toBe(5); + }); + + it("clears credentials with an encoded CAS query", async () => { + const result = await deleteOpenApiCredentials("source/1", 5, async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/credentials?expectedRevision=5"); + expect(init?.method).toBe("DELETE"); + return Response.json({ revision: 6, configuredSchemes: [] }); + }); + + expect(result.ok && result.value.configuredSchemes).toEqual([]); + }); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 41236be8e..72b1f808e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -30,6 +30,168 @@ const TokenListSchema = Schema.Struct({ tokens: Schema.Array(TokenMetadataSchema), }); +const ToolModeSchema = Schema.Literals(["enabled", "ask", "disabled"]); +const ModeProvenanceSchema = Schema.Literals(["tool_override", "source_override", "intrinsic"]); +const JsonObjectSchema = Schema.Record(Schema.String, Schema.Unknown); + +const SourceSchema = Schema.Struct({ + id: Schema.String, + kind: Schema.Literals(["openapi", "graphql", "mcp_http", "mcp_stdio"]), + slug: Schema.String, + displayName: Schema.String, + description: Schema.NullOr(Schema.String), + configuration: JsonObjectSchema, + modeOverride: Schema.NullOr(ToolModeSchema), + healthStatus: Schema.Literals(["unknown", "healthy", "error"]), + healthErrorCode: Schema.NullOr(Schema.String), + revision: Schema.Number, + catalogRevision: Schema.Number, + createdAt: Schema.Number, + updatedAt: Schema.Number, + lastRefreshedAt: Schema.NullOr(Schema.Number), + toolCount: Schema.Number, + tombstonedToolCount: Schema.Number, +}); + +const SourceListSchema = Schema.Struct({ + sources: Schema.Array(SourceSchema), + catalogRevision: Schema.Number, +}); + +const OAuthFlowSchema = Schema.Struct({ + authorizationUrl: Schema.NullOr(Schema.String), + tokenUrl: Schema.NullOr(Schema.String), + refreshUrl: Schema.NullOr(Schema.String), + scopes: Schema.Record(Schema.String, Schema.String), +}); + +const OAuthFlowsSchema = Schema.Struct({ + implicit: Schema.NullOr(OAuthFlowSchema), + password: Schema.NullOr(OAuthFlowSchema), + clientCredentials: Schema.NullOr(OAuthFlowSchema), + authorizationCode: Schema.NullOr(OAuthFlowSchema), +}); + +const OpenApiCredentialTypeSchema = Schema.Literals([ + "api_key", + "bearer", + "basic", + "manual_oauth_access_token", + "http", + "mutual_tls", +]); + +const OpenApiPreviewSchema = Schema.Struct({ + title: Schema.String, + description: Schema.NullOr(Schema.String), + toolCount: Schema.Number, + tools: Schema.Array( + Schema.Struct({ + preferredName: Schema.String, + displayName: Schema.String, + description: Schema.NullOr(Schema.String), + intrinsicMode: ToolModeSchema, + security: Schema.Array(Schema.Array(Schema.String)), + }), + ), + securitySchemes: Schema.Array( + Schema.Struct({ + name: Schema.String, + credentialType: OpenApiCredentialTypeSchema, + placement: Schema.NullOr(Schema.Literals(["header", "query", "cookie", "path"])), + supported: Schema.Boolean, + oauthFlows: Schema.NullOr(OAuthFlowsSchema), + }), + ), +}); + +const CredentialMetadataSchema = Schema.Struct({ + revision: Schema.Number, + configuredSchemes: Schema.Array( + Schema.Struct({ + name: Schema.String, + credentialType: Schema.Literals(["api_key", "bearer", "basic", "manual_oauth_access_token"]), + }), + ), +}); + +const CatalogSyncResultSchema = Schema.Struct({ + sourceId: Schema.String, + sourceRevision: Schema.Number, + catalogRevision: Schema.Number, + globalRevision: Schema.Number, + activeToolCount: Schema.Number, + tombstonedToolCount: Schema.Number, +}); + +const EffectiveModeSchema = Schema.Struct({ + mode: ToolModeSchema, + provenance: ModeProvenanceSchema, +}); + +const ToolSummarySchema = Schema.Struct({ + id: Schema.String, + sourceId: Schema.String, + sourceSlug: Schema.String, + stableKey: Schema.String, + localName: Schema.String, + callablePath: Schema.String, + sandboxPath: Schema.String, + displayName: Schema.String, + description: Schema.NullOr(Schema.String), + intrinsicMode: ToolModeSchema, + modeOverride: Schema.NullOr(ToolModeSchema), + effectiveMode: EffectiveModeSchema, + present: Schema.Boolean, + revision: Schema.Number, + createdAt: Schema.Number, + updatedAt: Schema.Number, + lastSeenAt: Schema.Number, + tombstonedAt: Schema.NullOr(Schema.Number), +}); + +const ToolRecordSchema = Schema.Struct({ + ...ToolSummarySchema.fields, + inputSchema: Schema.Unknown, + outputSchema: Schema.NullOr(Schema.Unknown), + inputTypescript: Schema.NullOr(Schema.String), + outputTypescript: Schema.NullOr(Schema.String), + typescriptDefinitions: Schema.Record(Schema.String, Schema.String), +}); + +const ToolPageSchema = Schema.Struct({ + items: Schema.Array(ToolSummarySchema), + total: Schema.Number, + hasMore: Schema.Boolean, + nextOffset: Schema.NullOr(Schema.Number), + catalogRevision: Schema.Number, +}); + +const BulkToolModeResultSchema = Schema.Struct({ + updatedCount: Schema.Number, + catalogRevision: Schema.Number, + sourceRevisions: Schema.Record(Schema.String, Schema.Number), +}); + +const RequestLogSchema = Schema.Struct({ + requestId: Schema.String, + actorApiTokenId: Schema.NullOr(Schema.String), + surface: Schema.Literals(["admin", "gateway", "cli", "mcp"]), + sourceId: Schema.NullOr(Schema.String), + toolId: Schema.NullOr(Schema.String), + pathSnapshot: Schema.NullOr(Schema.String), + outcome: Schema.Literals(["succeeded", "failed", "pending_approval", "denied"]), + errorCode: Schema.NullOr(Schema.String), + durationMs: Schema.Number, + approvalId: Schema.NullOr(Schema.String), + createdAt: Schema.Number, +}); + +const RequestLogPageSchema = Schema.Struct({ + items: Schema.Array(RequestLogSchema), + nextCursor: Schema.NullOr(Schema.String), +}); + const ErrorEnvelopeSchema = Schema.Struct({ error: Schema.Struct({ code: Schema.String, @@ -42,6 +204,18 @@ export type Bootstrap = typeof BootstrapSchema.Type; export type Session = typeof SessionSchema.Type; export type TokenMetadata = typeof TokenMetadataSchema.Type; export type CreatedToken = typeof CreatedTokenSchema.Type; +export type ToolMode = typeof ToolModeSchema.Type; +export type Source = typeof SourceSchema.Type; +export type SourceList = typeof SourceListSchema.Type; +export type OpenApiPreview = typeof OpenApiPreviewSchema.Type; +export type CatalogSyncResult = typeof CatalogSyncResultSchema.Type; +export type OpenApiCredentialMetadata = typeof CredentialMetadataSchema.Type; +export type ToolSummary = typeof ToolSummarySchema.Type; +export type ToolRecord = typeof ToolRecordSchema.Type; +export type ToolPage = typeof ToolPageSchema.Type; +export type BulkToolModeResult = typeof BulkToolModeResultSchema.Type; +export type RequestLog = typeof RequestLogSchema.Type; +export type RequestLogPage = typeof RequestLogPageSchema.Type; export class ApiError extends Schema.TaggedErrorClass()("ApiError", { code: Schema.String, @@ -61,6 +235,26 @@ const decodeBootstrap = Schema.decodeUnknownOption(Schema.fromJsonString(Bootstr const decodeSession = Schema.decodeUnknownOption(Schema.fromJsonString(SessionSchema)); const decodeCreatedToken = Schema.decodeUnknownOption(Schema.fromJsonString(CreatedTokenSchema)); const decodeTokenList = Schema.decodeUnknownOption(Schema.fromJsonString(TokenListSchema)); +const decodeSource = Schema.decodeUnknownOption(Schema.fromJsonString(SourceSchema)); +const decodeSourceList = Schema.decodeUnknownOption(Schema.fromJsonString(SourceListSchema)); +const decodeOpenApiPreview = Schema.decodeUnknownOption( + Schema.fromJsonString(OpenApiPreviewSchema), +); +const decodeCatalogSyncResult = Schema.decodeUnknownOption( + Schema.fromJsonString(CatalogSyncResultSchema), +); +const decodeCredentialMetadata = Schema.decodeUnknownOption( + Schema.fromJsonString(CredentialMetadataSchema), +); +const decodeToolRecord = Schema.decodeUnknownOption(Schema.fromJsonString(ToolRecordSchema)); +const decodeToolPage = Schema.decodeUnknownOption(Schema.fromJsonString(ToolPageSchema)); +const decodeBulkToolModeResult = Schema.decodeUnknownOption( + Schema.fromJsonString(BulkToolModeResultSchema), +); +const decodeRequestLog = Schema.decodeUnknownOption(Schema.fromJsonString(RequestLogSchema)); +const decodeRequestLogPage = Schema.decodeUnknownOption( + Schema.fromJsonString(RequestLogPageSchema), +); const decodeErrorEnvelope = Schema.decodeUnknownOption(Schema.fromJsonString(ErrorEnvelopeSchema)); export async function getBootstrap(fetcher: Fetcher = fetch, signal?: AbortSignal) { @@ -138,6 +332,265 @@ export async function revokeToken(tokenId: string, fetcher: Fetcher = fetch, sig return { ok: true, value: undefined } as const; } +export async function listSources(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/sources", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSourceList); +} + +export async function setSourceMode( + sourceId: string, + mode: ToolMode | null, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/mode`, + { + method: "PATCH", + body: JSON.stringify({ mode, expectedRevision }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSource); +} + +export async function deleteSource( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}`, + { method: "DELETE", signal }, + fetcher, + ); + if (!response.ok) return response; + return { ok: true, value: undefined } as const; +} + +export type OpenApiSpecInput = + | { readonly type: "inline"; readonly content: string } + | { readonly type: "url"; readonly url: string }; + +export type OpenApiStaticCredential = + | { readonly type: "api_key"; readonly value: string } + | { readonly type: "bearer"; readonly token: string } + | { readonly type: "basic"; readonly username: string; readonly password: string } + | { readonly type: "oauth_access_token"; readonly access_token: string }; + +export type OpenApiSourceInput = { + readonly kind: "openapi"; + readonly displayName: string; + readonly preferredSlug?: string; + readonly description?: string; + readonly spec: OpenApiSpecInput; + readonly allowPrivateNetwork?: boolean; + readonly credential?: { + readonly schemes: Readonly>; + }; +}; + +export async function previewOpenApiSource( + spec: OpenApiSpecInput, + allowPrivateNetwork: boolean, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/sources/openapi/preview", + { method: "POST", body: JSON.stringify({ spec, allowPrivateNetwork }), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOpenApiPreview); +} + +export async function createOpenApiSource( + input: OpenApiSourceInput, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/sources", + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSource); +} + +export async function refreshOpenApiSource( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/refresh`, + { method: "POST", body: "{}", signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCatalogSyncResult); +} + +export async function getOpenApiCredentials( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function putOpenApiCredentials( + sourceId: string, + expectedRevision: number, + schemes: Readonly>, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { + method: "PUT", + body: JSON.stringify({ expectedRevision, credential: { schemes } }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function deleteOpenApiCredentials( + sourceId: string, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials?expectedRevision=${encodeURIComponent(String(expectedRevision))}`, + { method: "DELETE", signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export type ToolListFilters = { + query?: string; + sourceId?: string; + mode?: ToolMode; + includeTombstoned?: boolean; + limit?: number; + offset?: number; +}; + +export async function listTools( + filters: ToolListFilters, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const parameters = new URLSearchParams(); + if (filters.query) parameters.set("query", filters.query); + if (filters.sourceId) parameters.set("sourceId", filters.sourceId); + if (filters.mode) parameters.set("mode", filters.mode); + if (filters.includeTombstoned) parameters.set("includeTombstoned", "true"); + if (filters.limit !== undefined) parameters.set("limit", String(filters.limit)); + if (filters.offset !== undefined) parameters.set("offset", String(filters.offset)); + const response = await request(`/api/v1/tools?${parameters}`, { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeToolPage); +} + +export async function getTool(toolId: string, fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request( + `/api/v1/tools/${encodeURIComponent(toolId)}`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeToolRecord); +} + +export async function setToolMode( + toolId: string, + mode: ToolMode | null, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/tools/${encodeURIComponent(toolId)}/mode`, + { + method: "PATCH", + body: JSON.stringify({ mode, expectedRevision }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeToolRecord); +} + +export async function bulkSetToolModes( + toolIds: readonly string[], + mode: ToolMode | null, + expectedCatalogRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/tools/modes", + { + method: "PATCH", + body: JSON.stringify({ + selection: { type: "tool_ids", toolIds, expectedCatalogRevision }, + mode, + }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeBulkToolModeResult); +} + +export async function listRequestLogs( + cursor: string | null, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const parameters = new URLSearchParams({ limit: "50" }); + if (cursor) parameters.set("cursor", cursor); + const response = await request(`/api/v1/request-logs?${parameters}`, { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeRequestLogPage); +} + +export async function getRequestLog( + requestId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/request-logs/${encodeURIComponent(requestId)}`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeRequestLog); +} + async function request(path: string, init: RequestInit, fetcher: Fetcher, includeCsrf = true) { if (!path.startsWith("/api/")) { return failure( diff --git a/web/src/lib/catalog-state.test.ts b/web/src/lib/catalog-state.test.ts new file mode 100644 index 000000000..76b8fde51 --- /dev/null +++ b/web/src/lib/catalog-state.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ApiError } from "$lib/api"; +import { + beginIdentityResourceLoad, + beginResourceLoad, + createLatestRequest, + emptyResource, + settleResourceLoad, +} from "./catalog-state"; + +describe("catalog resource state", () => { + it("distinguishes loading, empty, error, and stale data", () => { + const loading = emptyResource(); + const empty = settleResourceLoad(loading, { ok: true, value: [] }); + const refreshing = beginResourceLoad(empty); + const error = new ApiError({ + code: "offline", + displayMessage: "offline", + requestId: null, + status: 0, + }); + const failedEmpty = settleResourceLoad(loading, { ok: false, error }); + const loaded = settleResourceLoad(loading, { ok: true, value: ["tool"] }); + const stale = settleResourceLoad(loaded, { ok: false, error }); + + expect(loading).toMatchObject({ loading: true, data: null, stale: false }); + expect(empty).toMatchObject({ loading: false, data: [], stale: false }); + expect(refreshing).toMatchObject({ loading: true, data: [], stale: false }); + expect(failedEmpty).toMatchObject({ loading: false, data: null, stale: false }); + expect(stale).toMatchObject({ loading: false, data: ["tool"], stale: true, error }); + }); + + it("can discard retained detail data when a same-identity reload fails", () => { + const loaded = settleResourceLoad(emptyResource(), { ok: true, value: "tool-a" }); + const refreshing = beginIdentityResourceLoad(loaded, "a", "a").state; + const error = new ApiError({ + code: "network_error", + displayMessage: "offline", + requestId: null, + status: 0, + }); + const failed = settleResourceLoad( + refreshing, + { ok: false, error }, + { + retainDataOnError: false, + }, + ); + + expect(failed).toEqual({ data: null, loading: false, stale: false, error }); + }); + + it("rejects an older completion after a newer request wins", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + let resolveA = (_value: string) => {}; + let resolveB = (_value: string) => {}; + const a = new Promise((resolve) => (resolveA = resolve)); + const b = new Promise((resolve) => (resolveB = resolve)); + + latest.start( + () => a, + (value) => commits.push(value), + () => {}, + ); + latest.start( + () => b, + (value) => commits.push(value), + () => {}, + ); + resolveB("b"); + await b; + resolveA("a"); + await a; + await Promise.resolve(); + + expect(commits).toEqual(["b"]); + }); + + it("reports an unexpected rejection through the current request contract", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + const rejections: unknown[] = []; + let reject = (_error: unknown) => {}; + const pending = new Promise((_resolve, fail) => (reject = fail)); + const failure = { _tag: "UnexpectedTestRejection" } as const; + + latest.start( + () => pending, + (value) => commits.push(value), + (error) => rejections.push(error), + ); + await Promise.resolve(); + reject(failure); + await Promise.resolve(); + await Promise.resolve(); + + expect(commits).toEqual([]); + expect(rejections).toEqual([failure]); + }); + + it("ignores an older rejection after a newer request wins", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + const rejections: unknown[] = []; + let rejectA = (_error: unknown) => {}; + let resolveB = (_value: string) => {}; + const a = new Promise((_resolve, reject) => (rejectA = reject)); + const b = new Promise((resolve) => (resolveB = resolve)); + + latest.start( + () => a, + (value) => commits.push(value), + (error) => rejections.push(error), + ); + latest.start( + () => b, + (value) => commits.push(value), + (error) => rejections.push(error), + ); + await Promise.resolve(); + resolveB("b"); + await b; + rejectA({ _tag: "LateTestRejection" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(commits).toEqual(["b"]); + expect(rejections).toEqual([]); + }); + + it("retains data for a refresh but clears it across detail identities", () => { + const loaded = settleResourceLoad(emptyResource(), { ok: true, value: "tool-a" }); + + expect(beginIdentityResourceLoad(loaded, "a", "a")).toEqual({ + identity: "a", + state: { data: "tool-a", loading: true, error: null, stale: false }, + }); + expect(beginIdentityResourceLoad(loaded, "a", "b")).toEqual({ + identity: "b", + state: { data: null, loading: true, error: null, stale: false }, + }); + }); + + it("never leaves a previous tool list writable after a new list identity fails", () => { + const loaded = settleResourceLoad(emptyResource(), { + ok: true, + value: ["old-tool"], + }); + const changed = beginIdentityResourceLoad(loaded, "source-a", "source-b").state; + const error = new ApiError({ + code: "network_error", + displayMessage: "offline", + requestId: null, + status: 0, + }); + const failed = settleResourceLoad(changed, { ok: false, error }); + + expect(changed.data).toBeNull(); + expect(failed).toMatchObject({ data: null, loading: false, stale: false, error }); + }); + + it("rejects completion after the owner is disposed", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + let resolve = (_value: string) => {}; + const pending = new Promise((done) => (resolve = done)); + const dispose = latest.start( + () => pending, + (value) => commits.push(value), + () => {}, + ); + + dispose(); + resolve("late"); + await pending; + await Promise.resolve(); + + expect(commits).toEqual([]); + }); + + it("ignores a rejected task after the owner is disposed", async () => { + const latest = createLatestRequest(); + const rejections: unknown[] = []; + let reject = (_error: unknown) => {}; + const pending = new Promise((_resolve, fail) => (reject = fail)); + const dispose = latest.start( + () => pending, + () => {}, + (error) => rejections.push(error), + ); + + await Promise.resolve(); + dispose(); + reject({ _tag: "LateDisposedTestRejection" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(rejections).toEqual([]); + }); +}); diff --git a/web/src/lib/catalog-state.ts b/web/src/lib/catalog-state.ts new file mode 100644 index 000000000..8bbb430ce --- /dev/null +++ b/web/src/lib/catalog-state.ts @@ -0,0 +1,101 @@ +import { ApiError, type ApiResult, type ToolMode } from "$lib/api"; + +export type ResourceState = { + readonly data: Value | null; + readonly loading: boolean; + readonly error: ApiError | null; + readonly stale: boolean; +}; + +export function emptyResource(): ResourceState { + return { data: null, loading: true, error: null, stale: false }; +} + +export function beginResourceLoad(state: ResourceState) { + return { ...state, loading: true, error: null }; +} + +export function beginIdentityResourceLoad( + state: ResourceState, + currentIdentity: string | null, + nextIdentity: string, +) { + return { + identity: nextIdentity, + state: currentIdentity === nextIdentity ? beginResourceLoad(state) : emptyResource(), + }; +} + +export function settleResourceLoad( + state: ResourceState, + result: ApiResult, + options: { readonly retainDataOnError?: boolean } = {}, +) { + if (result.ok) { + return { data: result.value, loading: false, error: null, stale: false }; + } + const data = options.retainDataOnError === false ? null : state.data; + return { + data, + loading: false, + error: result.error, + stale: data !== null, + }; +} + +export function unexpectedRequestError() { + return new ApiError({ + code: "unexpected_client_error", + displayMessage: "The request ended unexpectedly. Try again.", + requestId: null, + status: 0, + }); +} + +export function createLatestRequest() { + let generation = 0; + let activeController: AbortController | null = null; + + function start( + task: (signal: AbortSignal) => Promise, + commit: (value: Value) => void, + reject: (error: unknown) => void, + ) { + activeController?.abort(); + const mine = ++generation; + const controller = new AbortController(); + activeController = controller; + void task(controller.signal).then( + (value) => { + if (!controller.signal.aborted && mine === generation) commit(value); + }, + (error: unknown) => { + if (!controller.signal.aborted && mine === generation) reject(error); + }, + ); + + return () => { + controller.abort(); + if (mine === generation) { + generation += 1; + activeController = null; + } + }; + } + + return { start }; +} + +export const toolModes = ["enabled", "ask", "disabled"] as const satisfies readonly ToolMode[]; + +export function modeLabel(mode: ToolMode) { + if (mode === "enabled") return "Enabled"; + if (mode === "ask") return "Ask"; + return "Disabled"; +} + +export function provenanceLabel(provenance: "tool_override" | "source_override" | "intrinsic") { + if (provenance === "tool_override") return "Tool override"; + if (provenance === "source_override") return "Source default"; + return "Tool default"; +} diff --git a/web/src/lib/catalog-url.test.ts b/web/src/lib/catalog-url.test.ts new file mode 100644 index 000000000..8c5017617 --- /dev/null +++ b/web/src/lib/catalog-url.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + logsListKey, + logsUrl, + parseLogsUrl, + parseToolsUrl, + toolsListKey, + toolsUrl, +} from "./catalog-url"; + +describe("catalog URL state", () => { + it("normalizes invalid tool filters without hiding valid choices", () => { + const state = parseToolsUrl( + new URLSearchParams( + "q=deploy&source=github&mode=surprise&removed=true&offset=-4&tool=tool-1", + ), + ); + + expect(state).toEqual({ + q: "deploy", + source: "github", + mode: null, + removed: false, + offset: 0, + tool: "tool-1", + }); + }); + + it("creates bookmarkable tool URLs and resets offsets on filter changes", () => { + const state = parseToolsUrl(new URLSearchParams("source=github&offset=100&removed=1")); + expect(toolsUrl(state, { mode: "ask", offset: 0 })).toBe( + "/tools?source=github&mode=ask&removed=1", + ); + }); + + it("keeps log cursors and inline request selection independently addressable", () => { + const state = parseLogsUrl(new URLSearchParams("cursor=older-page&request=req-1")); + expect(state).toEqual({ cursor: "older-page", request: "req-1" }); + expect(logsUrl(state, { request: null })).toBe("/logs?cursor=older-page"); + expect(logsUrl(state, { cursor: null })).toBe("/logs?request=req-1"); + }); + + it("excludes detail-only selections from list request identities", () => { + const toolA = parseToolsUrl(new URLSearchParams("source=github&tool=a")); + const toolB = parseToolsUrl(new URLSearchParams("source=github&tool=b")); + const logA = parseLogsUrl(new URLSearchParams("cursor=older&request=a")); + const logB = parseLogsUrl(new URLSearchParams("cursor=older&request=b")); + + expect(toolsListKey(toolA)).toBe(toolsListKey(toolB)); + expect(logsListKey(logA)).toBe(logsListKey(logB)); + }); + + it("changes list identity for filters, offsets, and cursors", () => { + const first = parseToolsUrl(new URLSearchParams("source=github&offset=0")); + const second = parseToolsUrl(new URLSearchParams("source=github&offset=50")); + expect(toolsListKey(first)).not.toBe(toolsListKey(second)); + expect(logsListKey({ cursor: "a", request: null })).not.toBe( + logsListKey({ cursor: "b", request: null }), + ); + }); +}); diff --git a/web/src/lib/catalog-url.ts b/web/src/lib/catalog-url.ts new file mode 100644 index 000000000..7431716d4 --- /dev/null +++ b/web/src/lib/catalog-url.ts @@ -0,0 +1,82 @@ +import type { ToolMode } from "$lib/api"; + +const modes = new Set(["enabled", "ask", "disabled"]); +const maxSearchLength = 256; +const maxOffset = 1_000_000_000; + +export type ToolsUrlState = { + q: string; + source: string | null; + mode: ToolMode | null; + removed: boolean; + offset: number; + tool: string | null; +}; + +export function parseToolsUrl(parameters: URLSearchParams): ToolsUrlState { + const rawMode = parameters.get("mode"); + return { + q: (parameters.get("q") ?? "").slice(0, maxSearchLength), + source: nonEmpty(parameters.get("source")), + mode: isToolMode(rawMode) ? rawMode : null, + removed: parameters.get("removed") === "1", + offset: parseOffset(parameters.get("offset")), + tool: nonEmpty(parameters.get("tool")), + }; +} + +export function toolsUrl(state: ToolsUrlState, updates: Partial = {}) { + const next = { ...state, ...updates }; + const parameters = new URLSearchParams(); + if (next.q) parameters.set("q", next.q); + if (next.source) parameters.set("source", next.source); + if (next.mode) parameters.set("mode", next.mode); + if (next.removed) parameters.set("removed", "1"); + if (next.offset > 0) parameters.set("offset", String(next.offset)); + if (next.tool) parameters.set("tool", next.tool); + const search = parameters.toString(); + return search ? `/tools?${search}` : "/tools"; +} + +export function toolsListKey(state: ToolsUrlState) { + return JSON.stringify([state.q, state.source, state.mode, state.removed, state.offset]); +} + +export type LogsUrlState = { + cursor: string | null; + request: string | null; +}; + +export function parseLogsUrl(parameters: URLSearchParams): LogsUrlState { + return { + cursor: nonEmpty(parameters.get("cursor")), + request: nonEmpty(parameters.get("request")), + }; +} + +export function logsUrl(state: LogsUrlState, updates: Partial = {}) { + const next = { ...state, ...updates }; + const parameters = new URLSearchParams(); + if (next.cursor) parameters.set("cursor", next.cursor); + if (next.request) parameters.set("request", next.request); + const search = parameters.toString(); + return search ? `/logs?${search}` : "/logs"; +} + +export function logsListKey(state: LogsUrlState) { + return state.cursor ?? ""; +} + +function isToolMode(value: string | null): value is ToolMode { + return value !== null && modes.has(value as ToolMode); +} + +function nonEmpty(value: string | null) { + return value === null || value === "" ? null : value; +} + +function parseOffset(value: string | null) { + if (value === null || !/^\d+$/.test(value)) return 0; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? Math.min(parsed, maxOffset) : 0; +} diff --git a/web/src/lib/catalog-ux.test.ts b/web/src/lib/catalog-ux.test.ts new file mode 100644 index 000000000..e9828eebc --- /dev/null +++ b/web/src/lib/catalog-ux.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + broadConfirmationText, + bulkActionLabel, + inheritLabel, + previewToolKey, + requiresBroadConfirmation, + sourceModeImpact, +} from "./catalog-ux"; + +describe("catalog mode UX", () => { + it("requires confirmation for broad enable and disable changes", () => { + expect(requiresBroadConfirmation("enabled")).toBe(true); + expect(requiresBroadConfirmation("disabled")).toBe(true); + expect(requiresBroadConfirmation("ask")).toBe(false); + expect(requiresBroadConfirmation(null)).toBe(false); + }); + + it("names bulk actions with their target and selected count", () => { + expect(bulkActionLabel("ask", 3)).toBe("Apply Ask to 3 selected"); + expect(bulkActionLabel(null, 1)).toBe("Apply Inherit to 1 selected"); + expect(broadConfirmationText("disabled", 2)).toBe( + "Disabled 2 selected tools? This sets an explicit mode override on every selected tool.", + ); + }); + + it("explains inherited state and conservative source impact", () => { + expect(inheritLabel("enabled")).toBe("Inherit (currently Enabled)"); + expect(sourceModeImpact(1)).toContain("Up to 1 active tool"); + expect(sourceModeImpact(12)).toContain("Up to 12 active tools"); + expect(sourceModeImpact(12)).toContain("Tool overrides stay unchanged"); + }); + + it("keeps legal duplicate preview names collision-safe", () => { + expect(previewToolKey("duplicate", 0)).not.toBe(previewToolKey("duplicate", 1)); + }); +}); diff --git a/web/src/lib/catalog-ux.ts b/web/src/lib/catalog-ux.ts new file mode 100644 index 000000000..dd8d6f35c --- /dev/null +++ b/web/src/lib/catalog-ux.ts @@ -0,0 +1,33 @@ +import type { ToolMode } from "$lib/api"; + +export function requiresBroadConfirmation(mode: ToolMode | null) { + return mode === "enabled" || mode === "disabled"; +} + +export function sourceModeImpact(toolCount: number) { + const noun = toolCount === 1 ? "tool" : "tools"; + return `Up to ${toolCount} active ${noun} may inherit this source default. Tool overrides stay unchanged.`; +} + +export function bulkActionLabel(mode: ToolMode | null, count: number) { + const target = mode === null ? "Inherit" : modeName(mode); + return `Apply ${target} to ${count} selected`; +} + +export function broadConfirmationText(mode: ToolMode, count: number) { + return `${modeName(mode)} ${count} selected ${count === 1 ? "tool" : "tools"}? This sets an explicit mode override on every selected tool.`; +} + +export function modeName(mode: ToolMode) { + if (mode === "enabled") return "Enabled"; + if (mode === "ask") return "Ask"; + return "Disabled"; +} + +export function inheritLabel(mode: ToolMode) { + return `Inherit (currently ${modeName(mode)})`; +} + +export function previewToolKey(preferredName: string, index: number) { + return `${index}:${preferredName}`; +} diff --git a/web/src/lib/openapi-credentials.test.ts b/web/src/lib/openapi-credentials.test.ts new file mode 100644 index 000000000..3bdc15aa9 --- /dev/null +++ b/web/src/lib/openapi-credentials.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + buildCredentialMap, + duplicateCredentialNames, + type CredentialDraft, +} from "./openapi-credentials"; + +function draft( + name: string, + credentialType: CredentialDraft["credentialType"], + value: string, + username = "", +): CredentialDraft { + return { key: name, name, credentialType, enabled: true, value, username }; +} + +describe("OpenAPI credential drafts", () => { + it("serializes every supported credential without changing OAuth field names", () => { + expect( + buildCredentialMap([ + draft("key", "api_key", "key-secret"), + draft("bearer", "bearer", "bearer-secret"), + draft("basic", "basic", "password", "davis"), + draft("oauth", "oauth_access_token", "oauth-secret"), + ]), + ).toEqual({ + key: { type: "api_key", value: "key-secret" }, + bearer: { type: "bearer", token: "bearer-secret" }, + basic: { type: "basic", username: "davis", password: "password" }, + oauth: { type: "oauth_access_token", access_token: "oauth-secret" }, + }); + }); + + it("ignores disabled preview schemes", () => { + expect( + buildCredentialMap([{ ...draft("unused", "bearer", "secret"), enabled: false }]), + ).toEqual({}); + }); + + it("rejects duplicate trimmed scheme names without dropping a secret", () => { + const rows = [draft("auth", "bearer", "one"), draft(" auth ", "bearer", "two")]; + expect(duplicateCredentialNames(rows)).toEqual(["auth"]); + expect(buildCredentialMap(rows)).toBeNull(); + }); + + it("rejects enabled credentials with missing names or secret values", () => { + expect(buildCredentialMap([draft("", "api_key", "secret")])).toBeNull(); + expect(buildCredentialMap([draft("auth", "api_key", "")])).toBeNull(); + }); +}); diff --git a/web/src/lib/openapi-credentials.ts b/web/src/lib/openapi-credentials.ts new file mode 100644 index 000000000..bba9371e9 --- /dev/null +++ b/web/src/lib/openapi-credentials.ts @@ -0,0 +1,49 @@ +import type { OpenApiStaticCredential } from "$lib/api"; + +export type SupportedCredentialType = "api_key" | "bearer" | "basic" | "oauth_access_token"; + +export type CredentialDraft = { + key: string; + name: string; + credentialType: SupportedCredentialType; + enabled: boolean; + value: string; + username: string; +}; + +export function buildCredentialMap(rows: readonly CredentialDraft[]) { + const credentials: Record = {}; + const names = new Set(); + for (const row of rows) { + if (!row.enabled) continue; + const name = row.name.trim(); + if (name === "" || row.value === "" || names.has(name)) return null; + names.add(name); + if (row.credentialType === "api_key") { + credentials[name] = { type: "api_key", value: row.value }; + } else if (row.credentialType === "bearer") { + credentials[name] = { type: "bearer", token: row.value }; + } else if (row.credentialType === "oauth_access_token") { + credentials[name] = { type: "oauth_access_token", access_token: row.value }; + } else { + credentials[name] = { type: "basic", username: row.username, password: row.value }; + } + } + return credentials; +} + +export function duplicateCredentialNames(rows: readonly CredentialDraft[]) { + const seen = new Set(); + const duplicates = new Set(); + for (const row of rows) { + if (!row.enabled) continue; + const name = row.name.trim(); + if (name !== "" && seen.has(name)) duplicates.add(name); + seen.add(name); + } + return [...duplicates]; +} + +export function isSupportedCredentialType(value: string): value is SupportedCredentialType { + return ["api_key", "bearer", "basic", "oauth_access_token"].includes(value); +} diff --git a/web/src/routes/logs/+page.svelte b/web/src/routes/logs/+page.svelte index ea6fc5f08..aad791b06 100644 --- a/web/src/routes/logs/+page.svelte +++ b/web/src/routes/logs/+page.svelte @@ -1,15 +1,288 @@ - +
+
+

Metadata only

+

Safe operational history

+
+

+ Arguments, response bodies, headers, credentials, and tokens are never included in this view. +

+
+ + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded request page while Executor reconnects. + +
+ {:else if resource.error !== null} +
+ + +
+ {/if} + +
+
+
+

Newest first

+

Gateway activity

+
+
+ {#if resource.loading}Refreshing...{/if} + +
+
+ {#if resource.data === null && resource.loading} +
Loading request logs...
+ {:else if resource.data !== null && resource.data.items.length === 0} +
+

No requests have been recorded on this page.

+ {#if urlState.cursor !== null}Return to newest requests{/if} +
+ {:else if resource.data !== null} +
+ + + + + {#each resource.data.items as log (log.requestId)} + + + + + + + + {/each} + +
Request metadata, newest first.
RequestRouteOutcomeTimingDetails
{log.requestId}{formatTime(log.createdAt)} · {log.surface}{log.pathSnapshot ?? "Unknown path"}{log.sourceId ?? "Source unavailable"}{outcomeLabel(log.outcome)}{#if log.errorCode !== null}{log.errorCode}{/if}{log.durationMs} ms (detailReturnId = log.requestId)}>Inspect
+
+ + {/if} +
+ + {#if urlState.request !== null} + + {/if}
diff --git a/web/src/routes/sources/+page.svelte b/web/src/routes/sources/+page.svelte index 8e958ee30..a8714d587 100644 --- a/web/src/routes/sources/+page.svelte +++ b/web/src/routes/sources/+page.svelte @@ -1,15 +1,1088 @@ - + {#if conflictNotice !== null} +
+ {conflictNotice} +
+ {/if} + {#if importNotice !== null}
+ {importNotice} +
{/if} + +
+ Connect an OpenAPI source +
+
+ Specification location + + +
+ {#if locatorType === "url"} + + {:else} + + {/if} + +

+ Keep this off unless the specification or API intentionally runs on your local network. +

+ + +
+ + {#if importError !== null}{/if} + {#if preview !== null} +
{ + event.preventDefault(); + void createSource(); + }} + > +
+

Preview

+

{preview.title}

+

{preview.description ?? "No API description provided."}

+
+ {preview.toolCount} tools found + {#if !previewCurrent}

+ The specification changed. Preview it again before importing. +

{/if} +
+ {#each preview.tools.slice(0, 8) as tool, index (previewToolKey(tool.preferredName, index))} + {tool.displayName}{modeLabel(tool.intrinsicMode)} + {/each} + {#if preview.tools.length > 8}+ {preview.tools.length - 8} more{/if} +
+
+ + + + {#if preview.securitySchemes.length > 0} +
+ Authentication schemes +

+ Select every credential you want to configure. Tool requirements use OR between + groups and AND within a group. +

+ {#each preview.securitySchemes as scheme (scheme.name)} + {@const row = importCredentialRows.find( + (candidate) => candidate.name === scheme.name, + )} +
+ + {#if row?.enabled} + {#if row.credentialType === "basic"}{/if} + + {/if} + {#if row?.enabled && row.credentialType === "oauth_access_token"} +

+ Supply an access token manually. Executor does not run authorization flows or + refresh OAuth tokens yet. +

+ {/if} +
+ {/each} +
+ {/if} +
+ + {#if authenticatedPreviewWithoutCredentials()} +

+ This specification declares authentication, but no credentials are selected. Protected + tools will fail until credentials are configured. +

+ {/if} +

Credentials are encrypted locally and are never shown again.

+
+ {/if} +
+ + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded sources while Executor reconnects. + +
+ {:else if resource.error !== null} +
+ + +
+ {/if} + + {#if resource.data === null && resource.loading} +
Loading sources...
+ {:else if resource.data !== null && resource.data.sources.length === 0} +
+ 01 +

No sources connected

+

+ This instance has no source data yet. Use the OpenAPI importer above to connect the first + API. +

+
+ {:else if resource.data !== null} +
+ {#each resource.data.sources as source (source.id)} +
+
+
+

{kindLabel(source.kind)} · {source.slug}

+

{source.displayName}

+ {#if source.description !== null}

{source.description}

{/if} +
+ + {source.healthStatus} + +
+ +
+
+
Active tools
+
{source.toolCount}
+
+
+
Removed tools
+
{source.tombstonedToolCount}
+
+
+
Last refresh
+
{formatTime(source.lastRefreshedAt)}
+
+
+ + {#if source.healthErrorCode !== null} +

Refresh error: {source.healthErrorCode}

+ {/if} + +
+ Default tool behavior + + {#each toolModes as mode} + + {/each} +

{sourceModeImpact(source.toolCount)}

+ +
+ + {#if confirmingSourceMode === source.id} +
+ Confirm {modeLabel(stagedSourceMode(source.id, source.modeOverride) ?? "ask")} as the + source default? + {sourceModeImpact(source.toolCount)} + + +
+ {/if} + + {#if mutationErrors[source.id] !== undefined} + + {/if} + {#if credentialFailure?.sourceId === source.id} + + {/if} + +
+ + View tools + + {#if source.kind === "openapi"} + + + {/if} + {#if confirmingDelete === source.id} +
+ Delete source, compiled tools, and encrypted credentials? + Request-log metadata is retained without secret values. + + +
+ {:else} + + {/if} +
+ + {#if credentialEditorSource === source.id} +
{ + event.preventDefault(); + void saveCredentials(source.id); + }} + > +
+

Replace credentials

+

Existing values are hidden. Re-enter every credential you want to keep.

+
+ {#each credentialRows as row (row.key)} +
+ Credential + + + {#if row.credentialType === "basic"}{/if} + + {#if row.credentialType === "oauth_access_token"} +

+ Supply an access token manually. Executor does not run authorization flows or + refresh OAuth tokens yet. +

+ {/if} + +
+ {/each} + {#if credentialRows.length === 0}

+ No credentials are currently configured. +

{/if} +
+ + + {#if currentDuplicateCredentialNames.length > 0}

+ Each security scheme name must be unique. Duplicates: {currentDuplicateCredentialNames.join( + ", ", + )}. +

{/if} + {#if confirmingCredentialClear === source.id} + + + {:else} + + {/if} +
+
+ {/if} +
+ {/each} +
+ {/if}
diff --git a/web/src/routes/tools/+page.svelte b/web/src/routes/tools/+page.svelte index 60ffc48b4..8e303e139 100644 --- a/web/src/routes/tools/+page.svelte +++ b/web/src/routes/tools/+page.svelte @@ -1,15 +1,731 @@ - - + + + +
+ + + + + +
+ {#if sources.error !== null} +
+ Source names could not be loaded. The active source filter is still applied. +
+ {/if} + +
+
+

Behavior semantics

+

How tool modes work

+
+
+
+
Inherit
+
Follow the source default, then the tool's built-in default.
+
+
+
Enabled
+
Available without interactive approval.
+
+
+
Ask
+
Require interactive approval before execution.
+
+
+
Disabled
+
Unavailable to gateway callers.
+
+
+
+ + {#if conflictNotice !== null}
+ {conflictNotice} +
{/if} + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded tool page while Executor reconnects. + +
+ {:else if resource.error !== null} +
+ + +
+ {/if} + + {#if resource.data !== null} +
+
+ {selected.length} selected on this page + Bulk changes use catalog revision {resource.data.catalogRevision}. +
+
+ Set selected tools + {#each toolModes as mode} + + {/each} +
+
+ + +
+ {#if confirmingBulk !== null} +
+ {broadConfirmationText(confirmingBulk.mode, confirmingBulk.ids.length)} + + +
+ {/if} + {#if bulkNotice !== null}
+ {bulkNotice} +
{/if} + {#if mutationErrors.bulk !== undefined}{/if} +
+ {/if} + +
+
+
+

Global tool set

+

{resource.data?.total ?? 0} tools

+
+ {#if resource.loading}Refreshing...{/if} +
+ {#if resource.data === null && resource.loading} +
Loading tools...
+ {:else if resource.data !== null && resource.data.items.length === 0} +
+

No tools match this page or filter set.

+ {#if urlState.offset > 0} + + {/if} +
+ {:else if resource.data !== null} +
+ + + + + + + + {#each resource.data.items as tool (tool.id)} + {@const inherited = inheritedMode(tool)} + + + + + + + {/each} + +
Only tools on this page are included in bulk changes.
+ tool.present) && + resource.data.items + .filter((tool) => tool.present) + .every((tool) => selected.includes(tool.id))} + onchange={(event) => toggleCurrentPage(event.currentTarget.checked)} + /> + ToolBehaviorDetails
+ toggleSelected(tool.id, event.currentTarget.checked)} + /> + + {tool.displayName} + {tool.callablePath} + {tool.sourceSlug}{tool.description ? ` · ${tool.description}` : ""} + {#if !tool.present}Removed{/if} + +
+ Behavior for {tool.displayName} + + {#each toolModes as mode} + + {/each} +
+ {provenanceLabel(tool.effectiveMode.provenance)} + {#if mutationErrors[tool.id] !== undefined}{/if} +
(detailReturnId = tool.id)}>Inspect
+
+ + {/if} +
+ + {#if urlState.tool !== null} + + {/if}
diff --git a/web/src/styles.css b/web/src/styles.css index f02b0234a..8685137da 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -26,7 +26,9 @@ body { } button, -input { +input, +select, +textarea { font: inherit; } @@ -49,7 +51,7 @@ button { cursor: pointer; } -:where(a, button, input):focus-visible { +:where(a, button, input, select, textarea):focus-visible { outline: 3px solid rgba(145, 160, 255, 0.72); outline-offset: 3px; } @@ -389,7 +391,8 @@ label small { font-weight: 400; } -input { +input, +textarea { width: 100%; border: 1px solid #66728b; border-radius: 10px; @@ -404,6 +407,15 @@ input:focus { box-shadow: 0 0 0 3px rgba(113, 129, 255, 0.13); } +textarea:focus { + border-color: #7181ff; + box-shadow: 0 0 0 3px rgba(113, 129, 255, 0.13); +} + +textarea { + resize: vertical; +} + input:disabled { color: #a4aec0; background: #141923; @@ -413,6 +425,16 @@ input[readonly] { cursor: text; } +select { + width: 100%; + min-height: 2.75rem; + border: 1px solid #66728b; + border-radius: 10px; + padding: 0.68rem 2rem 0.68rem 0.8rem; + color: #edf0f6; + background: #0b1019; +} + .notice { margin-top: 1rem; padding: 0.8rem 0.9rem; @@ -665,6 +687,523 @@ td { white-space: nowrap; } +.loading-panel { + padding: 2rem; + color: #aeb8ca; +} + +.import-panel { + overflow: hidden; +} + +.import-panel > summary { + padding: 1.1rem 1.35rem; + color: #e4e8f1; + cursor: pointer; + font-weight: 750; +} + +.import-panel[open] > summary { + border-bottom: 1px solid #293145; +} + +.import-form, +.preview-panel { + display: grid; + gap: 1rem; + padding: 1.3rem; +} + +.import-form { + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; +} + +.import-form > :not(button) { + grid-column: 1 / -1; +} + +.private-network-choice { + margin-top: 0.25rem; +} + +.field-help { + margin: 0; + color: #9da8bb; + font-size: 0.74rem; + line-height: 1.5; +} + +.preview-panel { + border-top: 1px solid #293145; + background: rgba(12, 17, 27, 0.65); +} + +.preview-panel > div:first-child p:last-child { + margin: 0; + color: #a8b2c4; +} + +.preview-tools { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.preview-tools span { + display: grid; + gap: 0.2rem; + border: 1px solid #30394c; + border-radius: 9px; + padding: 0.55rem 0.7rem; + color: #d5dbe7; + background: #111722; + font-size: 0.75rem; +} + +.preview-tools small { + color: #909caf; +} + +.import-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.credential-schemes, +.credential-editor { + display: grid; + gap: 0.85rem; +} + +.credential-schemes { + margin: 0; + border: 1px solid #30394c; + border-radius: 11px; + padding: 1rem; +} + +.credential-schemes > legend, +.credential-row > legend { + padding: 0 0.35rem; + color: #aeb8ca; + font-size: 0.72rem; + font-weight: 750; +} + +.credential-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + min-width: 0; + border: 1px solid #293247; + border-radius: 10px; + padding: 0.85rem; + background: #0d131d; +} + +.credential-row > .checkbox-label, +.credential-row > .danger-link { + grid-column: 1 / -1; + justify-self: start; +} + +.credential-row .checkbox-label span, +.credential-row .checkbox-label small { + display: block; +} + +.credential-row .checkbox-label small { + margin-top: 0.2rem; + color: #939fb3; +} + +.credential-editor { + border-top: 1px solid #293247; + padding-top: 1rem; +} + +.credential-editor h3, +.credential-editor p { + margin: 0; +} + +.credential-editor p { + margin-top: 0.3rem; + color: #9da8bb; + font-size: 0.78rem; +} + +.wide-field { + grid-column: 1 / -1; +} + +.source-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 390px), 1fr)); + gap: 1.25rem; +} + +.source-card { + display: grid; + gap: 1.25rem; + padding: 1.45rem; +} + +.source-card-heading { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.source-card-heading h2, +.privacy-note h2 { + margin: 0.35rem 0 0.45rem; +} + +.source-card-heading p:last-child { + margin: 0; + color: #a8b2c4; + line-height: 1.55; +} + +.health-pill, +.outcome-pill { + display: inline-block; + border: 1px solid #4a556d; + border-radius: 999px; + padding: 0.28rem 0.55rem; + color: #c1cada; + background: #171e2b; + font-size: 0.68rem; + font-weight: 750; + text-transform: capitalize; +} + +.health-pill.healthy, +.outcome-pill:not(.error) { + border-color: #315b50; + color: #75d5b3; + background: #11231f; +} + +.health-pill.error, +.outcome-pill.error { + border-color: #6a3c48; + color: #f4bdc8; + background: #24141a; +} + +.outcome-pill.pending { + border-color: #75683f; + color: #f0e2b7; + background: #252015; +} + +.source-stats, +.detail-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; +} + +.source-stats div, +.detail-list div { + min-width: 0; + border: 1px solid #283146; + border-radius: 10px; + padding: 0.8rem; + background: #0d131d; +} + +.source-stats dt, +.detail-list dt { + color: #8f9bb1; + font-size: 0.65rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.source-stats dd, +.detail-list dd { + margin: 0.35rem 0 0; + overflow-wrap: anywhere; + color: #d7dde8; + font-size: 0.82rem; +} + +.source-error { + margin: 0; + color: #f0c1cb; + font-size: 0.78rem; +} + +.mode-control { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 1rem; + min-width: 0; + margin: 0; + border: 1px solid #30394c; + border-radius: 11px; + padding: 0.85rem; +} + +.mode-control legend { + padding: 0 0.35rem; + color: #aeb8ca; + font-size: 0.72rem; + font-weight: 750; +} + +.mode-control label, +.checkbox-label { + display: flex; + gap: 0.4rem; + align-items: center; + color: #c7cfdd; + font-size: 0.76rem; + font-weight: 600; +} + +.source-mode-control .mode-impact, +.source-mode-control > button { + flex-basis: 100%; +} + +.mode-impact { + margin: 0; + color: #9da8bb; + font-size: 0.74rem; + line-height: 1.45; +} + +.mode-control input, +.checkbox-label input, +th input, +td input { + min-width: 1.5rem; + min-height: 1.5rem; + width: auto; + margin: 0; + accent-color: #91a0ff; +} + +.source-actions, +.inline-confirm { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: center; +} + +.source-actions { + justify-content: space-between; +} + +.inline-confirm { + justify-content: flex-end; + width: 100%; + color: #f1c2cc; + font-size: 0.78rem; +} + +.button-link, +.detail-link, +.pagination a { + display: inline-flex; + min-height: 2.55rem; + align-items: center; + border: 1px solid #30384a; + border-radius: 10px; + padding: 0.68rem 0.95rem; + color: #dfe5f2; + background: #151b28; + font-size: 0.8rem; + font-weight: 700; + text-decoration: none; +} + +.button-link:hover, +.detail-link:hover, +.pagination a:hover { + border-color: #66728b; + background: #1b2333; +} + +.filter-bar { + display: grid; + grid-template-columns: minmax(220px, 1.5fr) repeat(2, minmax(150px, 0.65fr)) auto auto; + gap: 0.8rem; + align-items: end; + padding: 1rem; +} + +.filter-bar .checkbox-label { + min-height: 2.75rem; + white-space: nowrap; +} + +.bulk-bar { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + padding: 1rem 1.2rem; +} + +.bulk-confirm { + flex-basis: 100%; +} + +.bulk-bar > div:first-child { + display: grid; + gap: 0.25rem; + margin-right: auto; +} + +.bulk-bar small, +.muted-status { + color: #9ca7ba; + font-size: 0.72rem; +} + +.mode-control.compact { + padding: 0.6rem 0.75rem; +} + +.row-modes { + flex-wrap: wrap; + border: 0; + padding: 0; +} + +.mode-semantics { + display: grid; + grid-template-columns: minmax(180px, 0.45fr) minmax(0, 1.55fr); + gap: 1rem; + align-items: center; + padding: 1rem 1.2rem; +} + +.mode-semantics h2 { + margin: 0.3rem 0 0; +} + +.mode-semantics dl { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.6rem; + margin: 0; +} + +.mode-semantics dl div { + border-left: 2px solid #36415a; + padding-left: 0.65rem; +} + +.mode-semantics dt { + color: #dbe1ec; + font-size: 0.75rem; + font-weight: 750; +} + +.mode-semantics dd { + margin: 0.25rem 0 0; + color: #96a2b6; + font-size: 0.7rem; + line-height: 1.45; +} + +.empty-recovery, +.table-empty .button-link { + justify-content: center; + margin-top: 0.8rem; +} + +.row-modes label { + gap: 0.25rem; +} + +td strong, +td code { + display: block; +} + +.removed-row { + opacity: 0.68; +} + +.detail-link { + min-height: 2rem; + padding: 0.35rem 0.6rem; +} + +.pagination { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + align-items: center; + padding: 1rem 1.2rem; + color: #9da8bc; + font-size: 0.74rem; +} + +.pagination a:last-child { + justify-self: end; +} + +.detail-panel { + overflow: hidden; +} + +.detail-body { + display: grid; + gap: 1rem; + padding: 1.3rem; +} + +.detail-list { + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); +} + +.detail-body h3 { + margin: 0.6rem 0 0; + color: #dce2ed; + font-size: 0.85rem; +} + +.detail-body pre { + overflow: auto; + max-height: 420px; + margin: 0; + border: 1px solid #293247; + border-radius: 10px; + padding: 1rem; + color: #cbd4e4; + background: #090e16; + font-size: 0.72rem; +} + +.privacy-note { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: center; + padding: 1.2rem 1.4rem; +} + +.privacy-note p:last-child { + max-width: 560px; + margin: 0; + color: #aab4c5; + line-height: 1.55; +} + @media (max-width: 820px) { .app-frame { display: block; @@ -697,9 +1236,37 @@ td { } .placeholder-grid, - .token-layout { + .token-layout, + .filter-bar { + grid-template-columns: 1fr; + } + + .filter-bar .checkbox-label { + min-height: auto; + } + + .privacy-note { + align-items: flex-start; + flex-direction: column; + } + + .mode-semantics { grid-template-columns: 1fr; } + + .mode-semantics dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .import-form, + .import-fields, + .credential-row { + grid-template-columns: 1fr; + } + + .wide-field { + grid-column: auto; + } } @media (max-width: 640px) { @@ -770,6 +1337,20 @@ td { min-width: 0; overflow-wrap: anywhere; } + + td[data-label="Behavior"] { + align-items: stretch; + flex-direction: column; + text-align: left; + } + + td[data-label="Behavior"]::before { + flex-basis: auto; + } + + .row-modes { + flex-wrap: wrap; + } } @media (max-width: 520px) { @@ -835,4 +1416,28 @@ td { .reveal-card { padding: 1.1rem; } + + .source-card-heading, + .source-actions { + align-items: flex-start; + flex-direction: column; + } + + .source-stats { + grid-template-columns: 1fr; + } + + .pagination { + grid-template-columns: 1fr; + } + + .pagination a, + .pagination a:last-child { + justify-self: stretch; + justify-content: center; + } + + .mode-semantics dl { + grid-template-columns: 1fr; + } } From 03628717f35fa63551b730d57591d6f7b174cff1 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 24 Jun 2026 01:02:27 -0700 Subject: [PATCH 04/24] feat: add sandbox runtime and approvals --- Cargo.lock | 750 +++++ Cargo.toml | 14 +- docs/architecture.md | 100 +- docs/runtime.md | 133 + migrations/20260623030000_approvals.sql | 212 ++ .../20260623040000_gateway_idempotency.sql | 127 + .../20260623050000_approval_correlations.sql | 131 + .../20260623060000_approval_delivery_pins.sql | 10 + src/actor.rs | 109 + src/api.rs | 97 +- src/api/approvals.rs | 333 +++ src/api/openapi.rs | 878 +----- src/api/protocols.rs | 870 +++--- src/approval/mod.rs | 2062 +++++++++++++ src/approval/tests.rs | 1781 +++++++++++ src/catalog/mod.rs | 34 + src/catalog/store.rs | 126 +- src/database.rs | 4 + src/execution.rs | 440 +++ src/invocation/idempotency.rs | 1555 ++++++++++ src/invocation/mod.rs | 2599 +++++++++++++++++ src/lib.rs | 65 + src/main.rs | 84 +- src/openapi.rs | 25 +- src/protocols/mod.rs | 606 ++++ src/protocols/openapi.rs | 1030 +++++++ src/{api => }/request_logs.rs | 122 +- src/runtime/invocation.rs | 389 +++ src/runtime/mod.rs | 322 ++ src/runtime/parent.rs | 701 +++++ src/runtime/protocol.rs | 252 ++ src/runtime/transform.rs | 336 +++ src/runtime/worker.rs | 426 +++ src/tasks.rs | 111 + tests/approval_api_strictness.rs | 179 ++ tests/approval_service.rs | 441 +++ tests/catalog.rs | 26 +- tests/invocation_preparation.rs | 96 + tests/openapi_api.rs | 192 +- tests/openapi_atomic_create.rs | 22 + tests/openapi_gateway_contract.rs | 641 +++- tests/openapi_lifecycle.rs | 102 +- tests/openapi_parser.rs | 77 +- tests/runtime.rs | 516 ++++ tests/runtime_invocation.rs | 1339 +++++++++ web/src/lib/api.test.ts | 157 + web/src/lib/api.ts | 114 + web/src/lib/approval-state.test.ts | 152 + web/src/lib/approval-state.ts | 89 + web/src/lib/approval-url.test.ts | 54 + web/src/lib/approval-url.ts | 53 + web/src/routes/approvals/+page.svelte | 654 ++++- web/src/styles.css | 199 +- 53 files changed, 20623 insertions(+), 1314 deletions(-) create mode 100644 docs/runtime.md create mode 100644 migrations/20260623030000_approvals.sql create mode 100644 migrations/20260623040000_gateway_idempotency.sql create mode 100644 migrations/20260623050000_approval_correlations.sql create mode 100644 migrations/20260623060000_approval_delivery_pins.sql create mode 100644 src/actor.rs create mode 100644 src/api/approvals.rs create mode 100644 src/approval/mod.rs create mode 100644 src/approval/tests.rs create mode 100644 src/execution.rs create mode 100644 src/invocation/idempotency.rs create mode 100644 src/invocation/mod.rs create mode 100644 src/protocols/mod.rs create mode 100644 src/protocols/openapi.rs rename src/{api => }/request_logs.rs (68%) create mode 100644 src/runtime/invocation.rs create mode 100644 src/runtime/mod.rs create mode 100644 src/runtime/parent.rs create mode 100644 src/runtime/protocol.rs create mode 100644 src/runtime/transform.rs create mode 100644 src/runtime/worker.rs create mode 100644 src/tasks.rs create mode 100644 tests/approval_api_strictness.rs create mode 100644 tests/approval_service.rs create mode 100644 tests/runtime.rs create mode 100644 tests/runtime_invocation.rs create mode 100644 web/src/lib/approval-state.test.ts create mode 100644 web/src/lib/approval-state.ts create mode 100644 web/src/lib/approval-url.test.ts create mode 100644 web/src/lib/approval-url.ts diff --git a/Cargo.lock b/Cargo.lock index 503adddeb..707dbfdde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -109,6 +115,28 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -188,6 +216,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -266,6 +304,15 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.65" @@ -363,12 +410,35 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -384,6 +454,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -408,6 +484,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -501,6 +586,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + [[package]] name = "either" version = "1.16.0" @@ -519,6 +610,18 @@ dependencies = [ "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "equivalent" version = "1.0.2" @@ -557,12 +660,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "executor" version = "0.1.0" dependencies = [ "anyhow", "argon2", + "async-trait", "axum", "base64", "chacha20poly1305", @@ -573,9 +687,12 @@ dependencies = [ "http-body-util", "ipnet", "jsonschema", + "libc", + "oxc", "percent-encoding", "rand 0.8.6", "reqwest", + "rquickjs", "serde", "serde_json", "serde_yaml", @@ -615,6 +732,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -814,6 +941,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -854,6 +986,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha1-compact" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" + [[package]] name = "home" version = "0.5.12" @@ -1102,6 +1240,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1119,6 +1266,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-escape-simd" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e770254dd7802184595b1d30da2a15cb72569e2aca2b177aef8d22eac8a693" + [[package]] name = "jsonschema" version = "0.46.6" @@ -1266,6 +1419,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1277,6 +1440,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1412,6 +1581,406 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "oxc" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c837e177cfe9e45dc8b474b16565046b673e5e2e8c2a2aece798ff9a1e104602" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_codegen", + "oxc_diagnostics", + "oxc_parser", + "oxc_semantic", + "oxc_span", + "oxc_syntax", + "oxc_transformer", + "oxc_transformer_plugins", +] + +[[package]] +name = "oxc-browserslist" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b87743c2f6963036971ca587539cd9235693d953dc018a734014241afb20d5b" +dependencies = [ + "flate2", + "postcard", + "rustc-hash", + "serde", + "thiserror", +] + +[[package]] +name = "oxc-miette" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b776084bf11ad750806cb63f9ed2606a12ab374cf458f36a7731eba1f5d753fc" +dependencies = [ + "cfg-if", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "oxc-miette-derive" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e0bafc397eee2f94c5bf89bb5a82ba6d522d1b79e2924c8169f053febb433f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oxc_allocator" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34ec71e68e73a0ed7d929e58ab2bb4f58752dea944c08f14ccc3b8272c77946" +dependencies = [ + "allocator-api2", + "hashbrown 0.17.1", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa406c62bb247767a6a051be6ac3be7db6c4f818129ccae570da6ee9eb96ead0" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3706e193b659865e03155a44e443f0447abf6789f9a3eb436ae10e557afab899" +dependencies = [ + "phf", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5353724c6c835bfd37ed59db65ccd506b7721598bcbe4750eda165754d105463" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_codegen" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d57d63987a27e8e67bc40518a4133b96ad2c0475f4735b8110e577a25680a09e" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_index", + "oxc_semantic", + "oxc_sourcemap", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "oxc_compat" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a25f516a7f4c05f66da375d07b29a87fe0e6037c79374f409153cb9496f3e0" +dependencies = [ + "cow-utils", + "oxc-browserslist", + "oxc_syntax", + "rustc-hash", + "serde", +] + +[[package]] +name = "oxc_data_structures" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde9ca13ef89018fb4c18502cefb9668baac6066905d27a914c29fb5fae1c8ac" +dependencies = [ + "ropey", +] + +[[package]] +name = "oxc_diagnostics" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cbafa6cc3c38d72e35cd9337a38a4558e9fee4521fa5cabe321b90519e80aa" +dependencies = [ + "cow-utils", + "oxc-miette", + "percent-encoding", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55408e4f458ec4b9cd842f67364e9395b28bdf2201c7755ed04d6707c1084ea" +dependencies = [ + "cow-utils", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_regular_expression", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6c062d52b9ff15d118b87679bdd0a05c43599c1d55fd1cab280da1cc822858" + +[[package]] +name = "oxc_index" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efc796fc0c65a2a6bd40984b65b1052b5550b017af303f02b08794e304c14c8" +dependencies = [ + "bitflags", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e533f1345534dceaee6c2c7102e5f3d2e7466269199eca9abeb871e02a7496a4" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_semantic" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de31d2015099e33ecdc23ad59730b768a141a0b2941d43970a26ba50de003e10" +dependencies = [ + "itertools", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "self_cell", + "smallvec", +] + +[[package]] +name = "oxc_sourcemap" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac6db7d8c33f46a0b9ebb702c077364abcd6b64c3a3a97bb86b9f5b3554833c" +dependencies = [ + "base64-simd", + "json-escape-simd", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_span" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5245fa99a7c9405d57e2d9666afc9fd01ac644e37aff1cb3e1cedd50c17915" +dependencies = [ + "compact_str", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c61444d5681ce805fbbb5dc0498d980685a4c6a82dd57c2e17ff948041c1fb" +dependencies = [ + "compact_str", + "hashbrown 0.17.1", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296b94ecc1235a2b5ea2a47412d4bc573b7a25a93132a1a2f49c617d85b58995" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf", + "unicode-id-start", +] + +[[package]] +name = "oxc_transformer" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494dcdb3e8c8dcb50e2ce3ba238030c9116a8b47b54f55c0f0211d68ae66b183" +dependencies = [ + "base64", + "compact_str", + "hmac-sha1-compact", + "indexmap", + "itoa", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_compat", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_traverse", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_transformer_plugins" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ce80c16ee776e23a0bcb472153db9ec66c59b709fcdb63f43f7623cdfadce7" +dependencies = [ + "cow-utils", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_parser", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_transformer", + "oxc_traverse", + "rustc-hash", +] + +[[package]] +name = "oxc_traverse" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da52a3734ef3ad4c10ebb6f4e768f63ea1597677d53785e3ceb26d9735f5c1f8" +dependencies = [ + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_ecmascript", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + [[package]] name = "parking" version = "2.2.1" @@ -1467,6 +2036,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1517,6 +2129,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1774,6 +2398,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -1826,6 +2459,46 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "rquickjs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fee8c5383f0cfda3b980a80ca4520e726e09b593c59562f579daa51b6c20411" +dependencies = [ + "async-lock", + "hashbrown 0.17.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09" +dependencies = [ + "cc", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1918,6 +2591,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -2054,6 +2739,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2069,6 +2766,12 @@ dependencies = [ "serde", ] +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "socket2" version = "0.6.4" @@ -2291,6 +2994,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "stringprep" version = "0.1.5" @@ -2358,6 +3073,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -2592,12 +3318,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -2613,6 +3351,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 82de016b0..726825e30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] anyhow = "1" argon2 = "0.5" +async-trait = "0.1" axum = "0.8" base64 = "0.22" chacha20poly1305 = "0.10" @@ -15,9 +16,12 @@ hkdf = "0.12" hmac = "0.12" ipnet = "2.12.0" jsonschema = { version = "0.46.6", default-features = false } +libc = "0.2" +oxc = { version = "=0.137.0", default-features = false, features = ["semantic", "transformer", "codegen", "ast_visit"] } percent-encoding = "2" rand = "0.8.5" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +rquickjs = { version = "=0.12.0", default-features = false, features = ["std", "futures"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -30,15 +34,7 @@ sqlx = { version = "0.8", default-features = false, features = [ ] } subtle = "2" thiserror = "2" -tokio = { version = "1", features = [ - "macros", - "rt-multi-thread", - "net", - "signal", - "time", - "fs", - "sync", -] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "fs", "sync", "process", "io-util"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" diff --git a/docs/architecture.md b/docs/architecture.md index 70d6695d2..80b89e6e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,8 +100,8 @@ display name and callable name. The full callable path is `tools.` because that is the proxy root. Local names are normalized and collision suffixes are allocated in stable-key order. Existing and tombstoned names stay reserved, which keeps paths stable when upstream discovery reorders, -removes, or restores a tool. The source roots `tools`, `search`, `describe`, and -`executor` are reserved for the sandbox and built-in catalog helpers. +removes, or restores a tool. The source roots `tools`, `search`, `describe`, +`sources`, and `executor` are reserved for the sandbox and built-in catalog helpers. A source may expose at most 100,000 active tools and retain at most 25,000 tombstoned tool identities, for a hard 125,000-row history ceiling. Refreshes @@ -150,13 +150,14 @@ invocation routes own authentication, limits, logging, and typed dispatch; OpenAPI owns only its preview, compiler, credential adapter, and invocation adapter. -Invocation admission acquires a catalog read lease and reads tool presence, -effective mode, source configuration, credential revision, and typed binding in -one coherent SQLite snapshot. Policy-affecting writers take the matching write -gate, so the admitted policy cannot change before or during outbound execution. -Ask releases the lease without network work and retains a revision token. A -future approval continuation must reacquire a fresh lease and revalidate every -source, tool, binding, catalog, and credential revision before execution. +Invocation admission first reads tool presence, effective mode, the input +schema, typed-binding identity, and credential revision in one coherent SQLite +snapshot without decrypting credentials. Arguments are validated before policy +handling. Enabled calls reacquire a full catalog read lease, decrypt credentials +only in the parent process, and retain the lease through outbound completion. +Ask calls persist an encrypted approval and release the preflight lease without +building an outbound request. Approval execution reacquires a fresh lease and +revalidates every source, tool, binding, catalog, and credential revision. Administrator catalog reads require a session cookie. Catalog mutations also require the matching Origin, CSRF cookie, and CSRF header. Gateway discovery, @@ -225,9 +226,84 @@ subset: styles fail during import instead of producing tools that cannot execute. Enabled tools execute immediately, disabled or removed tools cannot reach the -transport, and Ask tools return the stable `approval_required` seam without -executing. Request logs remain metadata-only and never contain arguments, -credentials, upstream bodies, or results. +transport, and Ask tools return HTTP 202 with an opaque approval ID, expiry, +and owner-only status URL. Request logs remain metadata-only and never contain +arguments, credentials, upstream bodies, or results. + +## Persistent approvals and tool invocation + +`ToolCallService` is the protocol-neutral parent-side invocation boundary used +by the HTTP gateway and designed for the TypeScript runtime, CLI, and MCP host. +A tool call carries its request, actor token, surface, execution, call, path, +and argument snapshots. The sandbox can request a path and arguments, but it +cannot select credentials, bindings, policy, actor identity, or approval state. + +Ask arguments, input schemas, internal invocation snapshots, and terminal +results are encrypted with approval-ID-bound associated data. SQLite stores +only metadata snapshots and revision columns in plaintext. Arguments and +results never enter audit metadata or request logs. Administrator detail +responses decrypt only a conservative schema-shaped redaction. Only the active +API token that created an approval can poll or cancel it and retrieve its +terminal result. + +The fixed approval TTL is ten minutes. The persisted state machine is: + +```text +pending -> approved -> executing -> succeeded | failed | interrupted + -> denied | expired | canceled +approved -> stale | canceled +``` + +Administrator decisions use cookie authentication plus Origin and CSRF checks. +The decision is a SQLite compare-and-swap and its metadata-only audit event is +committed in the same transaction. Repeating the same decision is idempotent; +a conflicting decision or revision loses with a stable conflict. The gateway +owner may cancel only pending or approved work. Executing work is never labeled +canceled because bytes may already have reached the upstream service. +Runtime cancellation and approval claims share an execution gate. If +cancellation marks the continuation lost first, no later approval can claim +network work. If the claim commits first, the server owns that already-executing +side effect through truthful terminal completion. + +Every terminal transition also inserts a bounded, metadata-only request-log +event into a transactional outbox. A single background drainer sends those +events through the shared bounded log sink and acknowledges an outbox row only +after SQLite confirms the request-log write. Stable event IDs make a crash +between persistence and acknowledgement idempotent. Delivery retries never +delay an approval decision or approved tool execution. + +Approval acceptance starts a detached server-owned task. That task reacquires +the exact invocation revision, atomically verifies the original API token is +still active, commits `executing`, then performs network work while holding the +fresh catalog lease. No database transaction spans network activity. A source, +tool, mode, binding, credential, or relevant catalog change makes the approval +stale without dispatch. API token revocation and cancellation of its pending or +approved work happen in one immediate transaction. + +On startup, only generation-zero direct pending approvals remain decidable and +generation-zero approved calls are safely queued. Sandbox pending or approved +approvals tied to a lost worker generation become canceled or stale. Any row +left executing is marked interrupted and is never retried, because its external +side effect may have completed before the process stopped. +A persisted nondecreasing clock high-water mark prevents a backward wall-clock +adjustment from reviving expired work. Active counts, aggregate ciphertext, +individual payloads, and terminal history all have hard bounds. Terminal +retention uses insertion order rather than wall-clock order. + +Caller correlation is scoped by typed actor, execution ID, and call ID. A +nonterminal approval retains that correlation without expiry. On a terminal +transition, its correlation receives a fixed 24-hour expiry. Identical retries +during that window recover the original approval or its retained tombstone; +identity mismatches fail with a conflict. After the full 24 hours, and only +after any linked gateway idempotency response also expires, the same IDs may +represent a new call. Cleanup runs before the bounded correlation-cap check and +never removes active or unexpired entries. + +Approval APIs are: + +- `GET /api/v1/approvals` and `GET /api/v1/approvals/{id}` +- `POST /api/v1/approvals/{id}/decision` +- `GET` and `DELETE /api/v1/gateway/approvals/{id}` OAuth2 and OpenID Connect operations expose their declared flow metadata for a later OAuth setup UI. This slice accepts a manually supplied access token in diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 000000000..e7e4c41b5 --- /dev/null +++ b/docs/runtime.md @@ -0,0 +1,133 @@ +# Sandboxed TypeScript runtime + +Executor runs each TypeScript execution in a fresh hidden worker process. The +server process owns authentication, policy, approvals, credentials, network +access, tool dispatch, cancellation, and request logging. The worker receives +only source code, public limits, and sanitized tool results. + +## Integration contract + +`RuntimeManager::execute` accepts an `ExecutionRequest`, a +`HostToolDispatcher`, and an `ExecutionCancellation`. The worker can request +only a tool path and one JSON argument. The parent adds the execution identity +before calling `HostToolDispatcher::dispatch`. Implementations must select on +the supplied cancellation handle while waiting for an interactive approval. +They must also treat a dropped dispatch future as cancellation and remove any +pending approval for that execution. + +The application adapter captures the authenticated actor and surface outside +the worker, maps each runtime `ToolCall` into `invocation::ToolCallService`, and +waits for an Ask decision while selecting on cancellation. When an execution +ends, it calls `cancel_execution(execution_id)` so pending approval calls do not +outlive their QuickJS continuation. + +The adapter assigns a distinct request-log ID to every tool call while keeping +the HTTP request ID and execution ID as parent-side correlation. Worker +generations are positive signed-range integers. They are included in approval +records but expose no authority. Approval decisions start at most one +server-owned background invocation. The worker waiter only observes the stored +terminal result and never dispatches the upstream call itself. + +Expected upstream tool failures use `ToolResult::Failure`. They resolve in +TypeScript as `{ error: { code, message } }`, so a script can inspect and react +to them. IPC, worker, and internal bridge failures reject the tool promise or +fail the execution with a stable public code. + +The result contract keeps the final JSON result, emitted JSON items, captured +console entries, and parent-side tool-call correlation separate. Neither IPC +nor runtime output contains credentials, request headers, source bindings, or +approval records. + +## Source recovery and tool proxy + +Executor uses the first fenced code block when one is present. It accepts an +async body with top-level `await` and `return`, a named function declaration, a +function or arrow expression, a callable variable declaration, or a default +exported callable. Oxc parses and removes TypeScript syntax inside the worker. +Imports, module loading, decorators, enums, namespaces, and JSX fail closed. + +`tools` is a lazy recursive frozen proxy. Dotted and bracket access are +equivalent. `then` and symbol property reads return `undefined`, which prevents +accidental thenable behavior. A call forwards only its first argument and that +argument must be JSON serializable. `Promise.all` creates concurrent parent +dispatches, with at most 16 active calls and 128 total calls per execution. + +Three protocol-neutral discovery calls are handled entirely by the parent: +`tools.search`, `tools.describe`, and `tools.sources`. They use the same catalog +visibility rules as gateway discovery. `tools.sources` returns only public +source metadata and never returns source configuration or credentials. + +## Gateway execution API + +`POST /api/v1/gateway/execute` is bearer-token only. Its JSON body is +`{ "code": string, "timeoutMs"?: number }`. Authentication runs before JSON +body parsing. Code is limited to 1 MiB, and the timeout defaults to 30 seconds +with a five-minute maximum. + +The response contains `executionId`, `result`, `emits`, `console`, and `calls`. +The call records contain only worker call IDs, public paths, and sanitized +results. The endpoint holds the HTTP request open while Ask tools await an +administrator decision. It does not provide persistence or replay. A client +disconnect cancels the worker and all pending or approved calls that have not +started upstream execution. Already-executing approved work remains owned by +the server because an upstream side effect may already have occurred. + +The application-level `ExecutionService` owns actor context, runtime admission, +disconnect cancellation, worker construction, and active execution tracking. +HTTP only authenticates, parses, and maps the protocol response. CLI and MCP +can reuse the same service without rebuilding lifecycle logic. Graceful +shutdown stops new runtime admission, cancels active workers, waits for their +parent-side cleanup, then stops approval and request-log tasks before closing +SQLite. + +Before disconnect, revocation, shutdown, timeout, or worker-failure cancellation +reaches the runtime, execution records an in-memory lost-continuation barrier. +Approval decisions and approved-work claims share that gate, so cancellation +and the `approved -> executing` transition have one linearized winner. If +immediate SQLite cleanup repeatedly fails, a tracked retry task keeps the +barrier active. Shutdown aborts that retry deterministically, and startup +recovery terminalizes every pending or approved sandbox approval whose worker +generation was lost. + +The worker has no module loader and exposes no filesystem, environment, +process, FFI, or network API. `fetch`, `XMLHttpRequest`, `WebSocket`, `process`, +`require`, `module`, `Deno`, and `Bun` are absent. + +## Isolation and limits + +IPC uses a private inherited Unix socket, never standard output. Every +length-prefixed JSON frame has a strict version, execution generation, tagged +schema, 18 MiB frame ceiling, and 32 MiB aggregate ceiling. Call IDs are +monotonic. Unknown fields, duplicate or unknown IDs, wrong generations, +partial frames, invalid lengths, and excessive JSON depth or node counts fail +closed before serde allocation. + +QuickJS has a 64 MiB heap limit and 1 MiB engine stack limit. Source and +transformed code are each capped at 1 MiB. Arguments and final results are +capped at 8 MiB. Console capture is capped at 1,000 entries and 256 KiB. Emits +are capped at 100 items and 8 MiB. The server admits at most eight workers at +once. Additional executions fail immediately with `runtime_busy` rather than +forming an unbounded waiter queue. + +The child starts with an empty environment, null standard streams, a private +empty working directory, umask `077`, a new process group, and resource limits +for CPU, address space, process stack, open files, output files, and core +dumps. Executor intentionally does not set `RLIMIT_NPROC`, which would affect +the shared user account rather than one worker. Cancellation and timeout kill +the worker process group and reap the child before releasing its worker slot. +Linux also sets `no_new_privs` and a parent-death kill signal before exec. + +Linux applies the complete resource-limit contract. macOS uses the +same process, descriptor, environment, QuickJS, and IPC boundaries, but its +kernel may treat address-space and CPU limits less strictly. Executor does not +claim a macOS Seatbelt profile or container boundary. + +This is a capability, crash, and resource boundary for JavaScript, not a +multi-tenant native-code sandbox. A QuickJS, Oxc, or Rust memory-safety escape +would still run as the service account on both Linux and macOS. Executor does +not currently install a Linux seccomp or Landlock policy. + +The caller chooses a positive wall deadline of at most five minutes. The same +deadline drives parent timeout handling and the QuickJS interrupt handler. The +kernel CPU limit is the deadline rounded up to a whole second, with exact whole +seconds left unchanged and subsecond deadlines receiving a one-second minimum. diff --git a/migrations/20260623030000_approvals.sql b/migrations/20260623030000_approvals.sql new file mode 100644 index 000000000..d44834c9a --- /dev/null +++ b/migrations/20260623030000_approvals.sql @@ -0,0 +1,212 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE approval_clock ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + effective_now INTEGER NOT NULL CHECK (effective_now >= 0) +) STRICT; + +INSERT INTO approval_clock (id, effective_now) VALUES (1, 0); + +CREATE TABLE approvals ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE CHECK (length(id) BETWEEN 1 AND 128), + execution_id TEXT NOT NULL CHECK (length(execution_id) BETWEEN 1 AND 128), + call_id TEXT NOT NULL CHECK (length(call_id) BETWEEN 1 AND 128), + worker_generation INTEGER NOT NULL CHECK (worker_generation >= 0), + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('api_token', 'admin', 'system')), + actor_id TEXT NOT NULL CHECK (length(actor_id) BETWEEN 1 AND 128), + actor_api_token_id TEXT REFERENCES api_tokens(id) ON DELETE RESTRICT, + actor_name_snapshot TEXT + CHECK (actor_name_snapshot IS NULL OR length(actor_name_snapshot) BETWEEN 1 AND 200), + surface TEXT NOT NULL CHECK (surface IN ('gateway', 'cli', 'mcp')), + source_id TEXT NOT NULL CHECK (length(source_id) BETWEEN 1 AND 128), + tool_id TEXT NOT NULL CHECK (length(tool_id) BETWEEN 1 AND 128), + callable_path_snapshot TEXT NOT NULL + CHECK (length(callable_path_snapshot) BETWEEN 1 AND 512), + source_display_name_snapshot TEXT + CHECK (source_display_name_snapshot IS NULL OR length(source_display_name_snapshot) BETWEEN 1 AND 300), + tool_display_name_snapshot TEXT + CHECK (tool_display_name_snapshot IS NULL OR length(tool_display_name_snapshot) BETWEEN 1 AND 300), + mode_provenance TEXT NOT NULL + CHECK (mode_provenance IN ('tool_override', 'source_override', 'intrinsic')), + source_revision INTEGER NOT NULL CHECK (source_revision >= 0), + catalog_revision INTEGER NOT NULL CHECK (catalog_revision >= 0), + tool_revision INTEGER NOT NULL CHECK (tool_revision >= 0), + binding_revision INTEGER NOT NULL CHECK (binding_revision >= 0), + credential_revision INTEGER CHECK (credential_revision IS NULL OR credential_revision >= 0), + arguments_digest BLOB NOT NULL CHECK (length(arguments_digest) = 32), + arguments_ciphertext BLOB NOT NULL CHECK (length(arguments_ciphertext) BETWEEN 42 AND 8388650), + redacted_arguments_ciphertext BLOB NOT NULL + CHECK (length(redacted_arguments_ciphertext) BETWEEN 42 AND 8388650), + input_schema_ciphertext BLOB NOT NULL CHECK (length(input_schema_ciphertext) BETWEEN 42 AND 2097194), + output_schema_ciphertext BLOB CHECK ( + output_schema_ciphertext IS NULL + OR length(output_schema_ciphertext) BETWEEN 42 AND 2097194 + ), + invocation_snapshot_ciphertext BLOB NOT NULL + CHECK (length(invocation_snapshot_ciphertext) BETWEEN 42 AND 8388650), + result_ciphertext BLOB CHECK ( + result_ciphertext IS NULL + OR length(result_ciphertext) BETWEEN 42 AND 8388650 + ), + status TEXT NOT NULL CHECK ( + status IN ( + 'pending', 'approved', 'denied', 'expired', 'canceled', + 'executing', 'succeeded', 'failed', 'stale', 'interrupted' + ) + ), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + decision_id TEXT CHECK (decision_id IS NULL OR length(decision_id) BETWEEN 1 AND 128), + decision TEXT CHECK (decision IS NULL OR decision IN ('approve', 'deny')), + decided_by_admin_id INTEGER REFERENCES admins(id) ON DELETE SET NULL, + failure_code TEXT CHECK (failure_code IS NULL OR length(failure_code) BETWEEN 1 AND 128), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL CHECK (expires_at > created_at), + decided_at INTEGER, + execution_started_at INTEGER, + completed_at INTEGER, + CHECK ( + (actor_kind = 'api_token' AND actor_api_token_id IS NOT NULL AND actor_id = actor_api_token_id) + OR (actor_kind IN ('admin', 'system') AND actor_api_token_id IS NULL) + ), + CHECK ( + (decision_id IS NULL AND decision IS NULL AND decided_at IS NULL AND decided_by_admin_id IS NULL) + OR (decision_id IS NOT NULL AND decision IS NOT NULL AND decided_at IS NOT NULL) + ), + CHECK ( + status NOT IN ('approved', 'denied') + OR decision IS NOT NULL + ), + CHECK ( + (status = 'executing' AND execution_started_at IS NOT NULL) + OR status <> 'executing' + ), + CHECK ( + (status IN ('succeeded', 'failed', 'stale', 'interrupted', 'denied', 'expired', 'canceled') + AND completed_at IS NOT NULL) + OR status IN ('pending', 'approved', 'executing') + ), + UNIQUE (actor_kind, actor_id, execution_id, call_id) +) STRICT; + +CREATE INDEX approvals_admin_cursor_idx + ON approvals(sequence DESC); +CREATE INDEX approvals_status_cursor_idx + ON approvals(status, sequence DESC); +CREATE INDEX approvals_actor_cursor_idx + ON approvals(actor_kind, actor_id, sequence DESC); +CREATE INDEX approvals_execution_idx + ON approvals(execution_id, status); + +CREATE TABLE approval_terminal_order ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + approval_id TEXT NOT NULL UNIQUE + REFERENCES approvals(id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE approval_log_outbox ( + request_id TEXT PRIMARY KEY NOT NULL CHECK (length(request_id) BETWEEN 1 AND 128), + approval_id TEXT NOT NULL CHECK (length(approval_id) BETWEEN 1 AND 128), + actor_api_token_id TEXT CHECK ( + actor_api_token_id IS NULL OR length(actor_api_token_id) BETWEEN 1 AND 128 + ), + surface TEXT NOT NULL CHECK (surface IN ('gateway', 'cli', 'mcp')), + source_id TEXT NOT NULL CHECK (length(source_id) BETWEEN 1 AND 128), + tool_id TEXT NOT NULL CHECK (length(tool_id) BETWEEN 1 AND 128), + path_snapshot TEXT NOT NULL CHECK (length(path_snapshot) BETWEEN 1 AND 512), + outcome TEXT NOT NULL CHECK (outcome IN ('succeeded', 'failed', 'denied')), + error_code TEXT CHECK (error_code IS NULL OR length(error_code) BETWEEN 1 AND 128), + created_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE approval_log_outbox_state ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + dropped_count INTEGER NOT NULL DEFAULT 0 CHECK (dropped_count >= 0) +) STRICT; + +INSERT INTO approval_log_outbox_state (id, dropped_count) VALUES (1, 0); + +CREATE TRIGGER approvals_fixed_expiry +BEFORE UPDATE OF created_at, expires_at ON approvals +WHEN NEW.created_at <> OLD.created_at OR NEW.expires_at <> OLD.expires_at +BEGIN + SELECT RAISE(ABORT, 'approval expiry is immutable'); +END; + +CREATE TRIGGER approvals_immutable_snapshot +BEFORE UPDATE OF + id, execution_id, call_id, worker_generation, actor_kind, actor_id, actor_api_token_id, + surface, + actor_name_snapshot, source_id, tool_id, callable_path_snapshot, + source_display_name_snapshot, tool_display_name_snapshot, mode_provenance, + source_revision, catalog_revision, tool_revision, binding_revision, + credential_revision, arguments_digest, arguments_ciphertext, + redacted_arguments_ciphertext, + input_schema_ciphertext, output_schema_ciphertext, + invocation_snapshot_ciphertext +ON approvals +BEGIN + SELECT RAISE(ABORT, 'approval snapshot is immutable'); +END; + +CREATE TRIGGER approvals_legal_status_transition +BEFORE UPDATE OF status ON approvals +WHEN NEW.status <> OLD.status AND NOT ( + (OLD.status = 'pending' AND NEW.status IN ('approved', 'denied', 'expired', 'canceled')) + OR (OLD.status = 'approved' AND NEW.status IN ('executing', 'stale', 'canceled')) + OR (OLD.status = 'executing' AND NEW.status IN ('succeeded', 'failed', 'interrupted')) +) +BEGIN + SELECT RAISE(ABORT, 'illegal approval status transition'); +END; + +CREATE TRIGGER approvals_terminal_log_outbox +AFTER UPDATE OF status ON approvals +WHEN NEW.status <> OLD.status + AND NEW.status IN ('denied', 'expired', 'canceled', 'succeeded', 'failed', 'stale', 'interrupted') +BEGIN + INSERT OR IGNORE INTO approval_log_outbox ( + request_id, approval_id, actor_api_token_id, surface, source_id, tool_id, + path_snapshot, outcome, error_code, created_at + ) VALUES ( + substr(NEW.execution_id, 1, 48) || ':approval:' || NEW.id || ':' || NEW.status, + NEW.id, + NEW.actor_api_token_id, + NEW.surface, + NEW.source_id, + NEW.tool_id, + NEW.callable_path_snapshot, + CASE + WHEN NEW.status = 'succeeded' THEN 'succeeded' + WHEN NEW.status IN ('denied', 'expired', 'canceled') THEN 'denied' + ELSE 'failed' + END, + CASE NEW.status + WHEN 'denied' THEN 'approval_denied' + WHEN 'expired' THEN 'approval_expired' + WHEN 'canceled' THEN 'approval_canceled' + WHEN 'failed' THEN coalesce(NEW.failure_code, 'approval_execution_failed') + WHEN 'stale' THEN coalesce(NEW.failure_code, 'approval_stale') + WHEN 'interrupted' THEN coalesce(NEW.failure_code, 'approval_interrupted') + ELSE NULL + END, + NEW.updated_at + ); +END; + +CREATE TRIGGER approval_log_outbox_retention +AFTER INSERT ON approval_log_outbox +WHEN (SELECT count(*) FROM approval_log_outbox) > 10000 +BEGIN + UPDATE approval_log_outbox_state + SET dropped_count = dropped_count + ( + (SELECT count(*) FROM approval_log_outbox) - 10000 + ) + WHERE id = 1; + DELETE FROM approval_log_outbox + WHERE rowid IN ( + SELECT rowid FROM approval_log_outbox + ORDER BY rowid DESC LIMIT -1 OFFSET 10000 + ); +END; diff --git a/migrations/20260623040000_gateway_idempotency.sql b/migrations/20260623040000_gateway_idempotency.sql new file mode 100644 index 000000000..f40b73f8c --- /dev/null +++ b/migrations/20260623040000_gateway_idempotency.sql @@ -0,0 +1,127 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE gateway_idempotency_clock ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + effective_now INTEGER NOT NULL CHECK (effective_now >= 0) +) STRICT; + +INSERT INTO gateway_idempotency_clock (id, effective_now) VALUES (1, 0); + +CREATE TABLE gateway_invocation_idempotency ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE CHECK (length(id) BETWEEN 1 AND 128), + owner_api_token_id TEXT NOT NULL + REFERENCES api_tokens(id) ON DELETE CASCADE, + key_digest BLOB NOT NULL CHECK (length(key_digest) = 32), + request_digest BLOB NOT NULL CHECK (length(request_digest) = 32), + state TEXT NOT NULL CHECK ( + state IN ('reserved', 'executing', 'completed', 'indeterminate') + ), + approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL, + approval_correlation_id TEXT CHECK ( + approval_correlation_id IS NULL OR length(approval_correlation_id) BETWEEN 1 AND 128 + ), + response_kind TEXT CHECK ( + response_kind IS NULL OR response_kind IN ('tool', 'approval') + ), + response_ciphertext BLOB CHECK ( + response_ciphertext IS NULL + OR length(response_ciphertext) BETWEEN 42 AND 12582954 + ), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + expires_at INTEGER, + UNIQUE (owner_api_token_id, key_digest), + CHECK ( + (state IN ('reserved', 'executing') + AND response_kind IS NULL + AND response_ciphertext IS NULL + AND completed_at IS NULL + AND expires_at IS NULL) + OR (state = 'completed' + AND response_kind IS NOT NULL + AND response_ciphertext IS NOT NULL + AND completed_at IS NOT NULL + AND expires_at > completed_at) + OR (state = 'indeterminate' + AND response_kind IS NULL + AND response_ciphertext IS NULL + AND completed_at IS NOT NULL + AND expires_at > completed_at) + ) +) STRICT; + +CREATE INDEX gateway_invocation_idempotency_expiry_idx + ON gateway_invocation_idempotency(expires_at) + WHERE expires_at IS NOT NULL; + +CREATE INDEX gateway_invocation_idempotency_owner_idx + ON gateway_invocation_idempotency(owner_api_token_id, sequence DESC); + +CREATE INDEX gateway_invocation_idempotency_approval_correlation_idx + ON gateway_invocation_idempotency(approval_correlation_id) + WHERE approval_correlation_id IS NOT NULL; + +CREATE TRIGGER gateway_idempotency_immutable_binding +BEFORE UPDATE OF id, owner_api_token_id, key_digest, request_digest, created_at +ON gateway_invocation_idempotency +BEGIN + SELECT RAISE(ABORT, 'gateway idempotency binding is immutable'); +END; + +CREATE TRIGGER gateway_idempotency_legal_transition +BEFORE UPDATE OF state ON gateway_invocation_idempotency +WHEN NEW.state <> OLD.state AND NOT ( + (OLD.state = 'reserved' AND NEW.state IN ('executing', 'completed', 'indeterminate')) + OR (OLD.state = 'executing' AND NEW.state IN ('completed', 'indeterminate')) +) +BEGIN + SELECT RAISE(ABORT, 'illegal gateway idempotency state transition'); +END; + +CREATE TRIGGER gateway_idempotency_immutable_terminal +BEFORE UPDATE OF response_kind, response_ciphertext, completed_at, expires_at +ON gateway_invocation_idempotency +WHEN OLD.state IN ('completed', 'indeterminate') +BEGIN + SELECT RAISE(ABORT, 'terminal gateway idempotency response is immutable'); +END; + +CREATE TRIGGER gateway_idempotency_capture_approval_correlation_insert +AFTER INSERT ON gateway_invocation_idempotency +WHEN NEW.approval_id IS NOT NULL AND NEW.approval_correlation_id IS NULL +BEGIN + UPDATE gateway_invocation_idempotency + SET approval_correlation_id = NEW.approval_id + WHERE id = NEW.id; +END; + +CREATE TRIGGER gateway_idempotency_capture_approval_correlation_update +AFTER UPDATE OF approval_id ON gateway_invocation_idempotency +WHEN OLD.approval_id IS NULL AND NEW.approval_id IS NOT NULL +BEGIN + UPDATE gateway_invocation_idempotency + SET approval_correlation_id = NEW.approval_id + WHERE id = NEW.id; +END; + +CREATE TRIGGER gateway_idempotency_approval_correlation_transition +BEFORE UPDATE OF approval_correlation_id ON gateway_invocation_idempotency +WHEN NOT ( + OLD.approval_correlation_id IS NULL + AND NEW.approval_correlation_id = NEW.approval_id + AND NEW.approval_correlation_id IS NOT NULL +) +BEGIN + SELECT RAISE(ABORT, 'gateway approval correlation transition is invalid'); +END; + +CREATE TRIGGER gateway_idempotency_approval_reference_transition +BEFORE UPDATE OF approval_id ON gateway_invocation_idempotency +WHEN OLD.approval_id IS NOT NULL + AND NEW.approval_id IS NOT NULL + AND NEW.approval_id <> OLD.approval_id +BEGIN + SELECT RAISE(ABORT, 'gateway approval reference is immutable'); +END; diff --git a/migrations/20260623050000_approval_correlations.sql b/migrations/20260623050000_approval_correlations.sql new file mode 100644 index 000000000..e06cdc610 --- /dev/null +++ b/migrations/20260623050000_approval_correlations.sql @@ -0,0 +1,131 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE approval_correlations ( + execution_id TEXT NOT NULL CHECK (length(execution_id) BETWEEN 1 AND 128), + call_id TEXT NOT NULL CHECK (length(call_id) BETWEEN 1 AND 128), + approval_id TEXT NOT NULL UNIQUE CHECK (length(approval_id) BETWEEN 1 AND 128), + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('api_token', 'admin', 'system')), + actor_id TEXT NOT NULL CHECK (length(actor_id) BETWEEN 1 AND 128), + surface TEXT NOT NULL CHECK (surface IN ('gateway', 'cli', 'mcp')), + worker_generation INTEGER NOT NULL CHECK (worker_generation >= 0), + callable_path TEXT NOT NULL CHECK (length(callable_path) BETWEEN 1 AND 512), + arguments_digest BLOB NOT NULL CHECK (length(arguments_digest) = 32), + expires_at INTEGER CHECK (expires_at IS NULL OR expires_at >= 0), + PRIMARY KEY (actor_kind, actor_id, execution_id, call_id) +) STRICT; + +CREATE INDEX approval_correlations_expiry_idx + ON approval_correlations(expires_at) + WHERE expires_at IS NOT NULL; + +CREATE TABLE approval_correlation_state ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + correlation_count INTEGER NOT NULL CHECK (correlation_count BETWEEN 0 AND 100000) +) STRICT; + +INSERT INTO approval_correlation_state (id, correlation_count) VALUES (1, 0); + +INSERT INTO approval_correlations ( + execution_id, call_id, approval_id, actor_kind, actor_id, surface, + worker_generation, callable_path, arguments_digest, expires_at +) +SELECT + execution_id, call_id, id, actor_kind, actor_id, surface, + worker_generation, callable_path_snapshot, arguments_digest, + CASE + WHEN status IN ('denied', 'expired', 'canceled', 'succeeded', 'failed', 'stale', 'interrupted') + THEN completed_at + 86400 + ELSE NULL + END +FROM approvals; + +UPDATE approval_correlation_state +SET correlation_count = (SELECT count(*) FROM approval_correlations) +WHERE id = 1; + +CREATE TRIGGER approval_correlations_capacity +BEFORE INSERT ON approval_correlations +WHEN (SELECT correlation_count FROM approval_correlation_state WHERE id = 1) >= 100000 +BEGIN + SELECT RAISE(ABORT, 'approval correlation capacity reached'); +END; + +CREATE TRIGGER approval_correlations_count_insert +AFTER INSERT ON approval_correlations +BEGIN + UPDATE approval_correlation_state + SET correlation_count = correlation_count + 1 + WHERE id = 1; +END; + +CREATE TRIGGER approval_correlations_initial_expiry +BEFORE INSERT ON approval_correlations +WHEN NEW.expires_at IS NOT NULL +BEGIN + SELECT RAISE(ABORT, 'new approval correlation expiry must be null'); +END; + +CREATE TRIGGER approvals_insert_correlation +AFTER INSERT ON approvals +BEGIN + INSERT INTO approval_correlations ( + execution_id, call_id, approval_id, actor_kind, actor_id, surface, + worker_generation, callable_path, arguments_digest, expires_at + ) VALUES ( + NEW.execution_id, NEW.call_id, NEW.id, NEW.actor_kind, NEW.actor_id, NEW.surface, + NEW.worker_generation, NEW.callable_path_snapshot, NEW.arguments_digest, NULL + ); +END; + +CREATE TRIGGER approval_correlations_immutable +BEFORE UPDATE OF + execution_id, call_id, approval_id, actor_kind, actor_id, surface, + worker_generation, callable_path, arguments_digest +ON approval_correlations +BEGIN + SELECT RAISE(ABORT, 'approval correlation is immutable'); +END; + +CREATE TRIGGER approval_correlations_expiry_transition +BEFORE UPDATE OF expires_at ON approval_correlations +WHEN NOT ( + OLD.expires_at IS NULL + AND NEW.expires_at = ( + SELECT completed_at + 86400 + FROM approvals + WHERE id = OLD.approval_id + AND status IN ('denied', 'expired', 'canceled', 'succeeded', 'failed', 'stale', 'interrupted') + ) +) +BEGIN + SELECT RAISE(ABORT, 'approval correlation expiry transition is invalid'); +END; + +CREATE TRIGGER approvals_terminalize_correlation +AFTER UPDATE OF status ON approvals +WHEN NEW.status <> OLD.status + AND NEW.status IN ('denied', 'expired', 'canceled', 'succeeded', 'failed', 'stale', 'interrupted') +BEGIN + UPDATE approval_correlations + SET expires_at = NEW.completed_at + 86400 + WHERE approval_id = NEW.id AND expires_at IS NULL; +END; + +CREATE TRIGGER approval_correlations_delete_guard +BEFORE DELETE ON approval_correlations +WHEN OLD.expires_at IS NULL + OR OLD.expires_at > (SELECT effective_now FROM approval_clock WHERE id = 1) +BEGIN + SELECT RAISE(ABORT, 'unexpired approval correlation cannot be deleted'); +END; + +CREATE TRIGGER approval_correlations_count_delete +AFTER DELETE ON approval_correlations +BEGIN + UPDATE approval_correlation_state + SET correlation_count = correlation_count - 1 + WHERE id = 1; + DELETE FROM approvals + WHERE id = OLD.approval_id + AND status IN ('denied', 'expired', 'canceled', 'succeeded', 'failed', 'stale', 'interrupted'); +END; diff --git a/migrations/20260623060000_approval_delivery_pins.sql b/migrations/20260623060000_approval_delivery_pins.sql new file mode 100644 index 000000000..4cefa3a16 --- /dev/null +++ b/migrations/20260623060000_approval_delivery_pins.sql @@ -0,0 +1,10 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE approval_delivery_pins ( + approval_id TEXT PRIMARY KEY NOT NULL + REFERENCES approvals(id) ON DELETE RESTRICT, + ref_count INTEGER NOT NULL CHECK (ref_count BETWEEN 1 AND 128), + snapshot_ciphertext_bytes INTEGER NOT NULL + CHECK (snapshot_ciphertext_bytes BETWEEN 1 AND 67108864), + created_at INTEGER NOT NULL +) STRICT; diff --git a/src/actor.rs b/src/actor.rs new file mode 100644 index 000000000..d54fee5b3 --- /dev/null +++ b/src/actor.rs @@ -0,0 +1,109 @@ +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ActorKind { + ApiToken, + Admin, + System, +} + +impl ActorKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::ApiToken => "api_token", + Self::Admin => "admin", + Self::System => "system", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SystemActor { + LocalCli, +} + +impl SystemActor { + pub const fn as_str(self) -> &'static str { + match self { + Self::LocalCli => "local_cli", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ToolActor { + ApiToken { + id: String, + name_snapshot: Option, + }, + Admin { + id: i64, + }, + System(SystemActor), +} + +impl ToolActor { + pub fn api_token(id: impl Into, name_snapshot: Option) -> Self { + Self::ApiToken { + id: id.into(), + name_snapshot, + } + } + + pub const fn admin(id: i64) -> Self { + Self::Admin { id } + } + + pub const fn local_cli() -> Self { + Self::System(SystemActor::LocalCli) + } + + pub const fn kind(&self) -> ActorKind { + match self { + Self::ApiToken { .. } => ActorKind::ApiToken, + Self::Admin { .. } => ActorKind::Admin, + Self::System(_) => ActorKind::System, + } + } + + pub fn id(&self) -> String { + match self { + Self::ApiToken { id, .. } => id.clone(), + Self::Admin { id } => id.to_string(), + Self::System(actor) => actor.as_str().to_owned(), + } + } + + pub fn name_snapshot(&self) -> Option<&str> { + match self { + Self::ApiToken { name_snapshot, .. } => name_snapshot.as_deref(), + Self::Admin { .. } | Self::System(_) => None, + } + } + + pub fn api_token_id(&self) -> Option<&str> { + match self { + Self::ApiToken { id, .. } => Some(id), + Self::Admin { .. } | Self::System(_) => None, + } + } + + pub(crate) fn from_stored( + kind: &str, + id: String, + api_token_id: Option, + name_snapshot: Option, + ) -> Option { + match kind { + "api_token" if api_token_id.as_deref() == Some(id.as_str()) => { + Some(Self::ApiToken { id, name_snapshot }) + } + "admin" if api_token_id.is_none() => id.parse().ok().map(|id| Self::Admin { id }), + "system" if api_token_id.is_none() && id == SystemActor::LocalCli.as_str() => { + Some(Self::System(SystemActor::LocalCli)) + } + _ => None, + } + } +} diff --git a/src/api.rs b/src/api.rs index 30218292d..fc44ea26e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -27,22 +27,24 @@ use crate::{ catalog::CatalogStore, crypto::{generate_secret, hash_password, verify_password}, database::{Database, SETUP_TOKEN_TTL_SECONDS}, + execution::ExecutionService, + invocation::ToolCallService, + protocols::SourceService, + request_logs::RequestLogSink, + tasks::TaskTracker, unix_timestamp, }; +mod approvals; mod catalog; -mod openapi; +pub(crate) mod openapi; mod protocols; -mod request_logs; - -use request_logs::GatewayRequestLogSink; const SESSION_COOKIE: &str = "executor_session"; const CSRF_COOKIE: &str = "executor_csrf"; const CSRF_HEADER: &str = "x-executor-csrf"; const MAX_API_BODY_BYTES: usize = 16 * 1024; const PASSWORD_HASH_CONCURRENCY: usize = 2; -const GATEWAY_SEARCH_CONCURRENCY: usize = 1; const MAX_USERNAME_CHARACTERS: usize = 64; const MAX_USERNAME_BYTES: usize = 256; const MIN_PASSWORD_CHARACTERS: usize = 12; @@ -64,7 +66,10 @@ const MAX_TOKEN_LAST_USED_ATTEMPTS: usize = 4096; struct AppState { database: Database, catalog: CatalogStore, - request_logs: GatewayRequestLogSink, + sources: SourceService, + tool_calls: ToolCallService, + execution: ExecutionService, + request_logs: RequestLogSink, origin: Arc, session_ttl_seconds: i64, secure_cookies: bool, @@ -74,6 +79,7 @@ struct AppState { login_rate_limiter: Arc, token_last_used_tracker: Arc, trusted_proxies: Arc<[IpNet]>, + background_tasks: TaskTracker, } #[derive(Clone)] @@ -274,6 +280,7 @@ impl TokenLastUsedTracker { token_id: String, used_at: i64, request_id: String, + background_tasks: &TaskTracker, ) { let attempted_at = Instant::now(); let mut attempts = self @@ -302,7 +309,7 @@ impl TokenLastUsedTracker { drop(attempts); let tracker = Arc::clone(self); - tokio::spawn(async move { + background_tasks.spawn(async move { let _permit = permit; let database_guard = database; let result = sqlx::query( @@ -410,8 +417,30 @@ struct GatewayIdentity { struct AdminMutation(i64); +struct AdminAuthentication; + struct GatewayAuthentication(GatewayIdentity); +pub(crate) struct ApiServices { + sources: SourceService, + tool_calls: ToolCallService, + execution: ExecutionService, +} + +impl ApiServices { + pub(crate) fn new( + sources: SourceService, + tool_calls: ToolCallService, + execution: ExecutionService, + ) -> Self { + Self { + sources, + tool_calls, + execution, + } + } +} + struct AdminSession { id: i64, username: String, @@ -436,6 +465,23 @@ impl FromRequestParts for AdminMutation { } } +impl FromRequestParts for AdminAuthentication { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let request_id = parts + .extensions + .get::() + .expect("request ID middleware runs before authentication") + .clone(); + require_admin(&request_id, state, &parts.headers).await?; + Ok(Self) + } +} + impl FromRequestParts for GatewayAuthentication { type Rejection = ApiError; @@ -456,23 +502,30 @@ impl FromRequestParts for GatewayAuthentication { pub(crate) fn router( database: Database, catalog: CatalogStore, + services: ApiServices, + request_logs: RequestLogSink, + background_tasks: TaskTracker, config: &AppConfig, dummy_password_hash: String, ) -> Router { - let request_logs = GatewayRequestLogSink::new(database.clone(), catalog.clone()); + let gateway_search_slots = services.tool_calls.discovery_slots(); let state = AppState { database, catalog, + sources: services.sources, + tool_calls: services.tool_calls, + execution: services.execution, request_logs, origin: Arc::from(config.public_origin()), session_ttl_seconds: config.session_ttl_seconds, secure_cookies: config.origin.scheme() == "https", password_hash_slots: Arc::new(Semaphore::new(PASSWORD_HASH_CONCURRENCY)), - gateway_search_slots: Arc::new(Semaphore::new(GATEWAY_SEARCH_CONCURRENCY)), + gateway_search_slots, dummy_password_hash: Arc::from(dummy_password_hash), login_rate_limiter: Arc::new(LoginRateLimiter::new()), token_last_used_tracker: Arc::new(TokenLastUsedTracker::new()), trusted_proxies: config.trusted_proxies.clone(), + background_tasks, }; let middleware_state = state.clone(); @@ -485,6 +538,7 @@ pub(crate) fn router( .route("/api/v1/tokens/{id}", delete(revoke_token)) .route("/api/v1/gateway/whoami", get(gateway_whoami)) .merge(catalog::router()) + .merge(approvals::router()) .merge(protocols::router()) .merge(openapi::router()) .fallback(not_found) @@ -960,15 +1014,12 @@ async fn revoke_token( Path(token_id): Path, ) -> Result { require_admin_mutation(&request_id, &state, &headers).await?; - let changed = - sqlx::query("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") - .bind(unix_timestamp()) - .bind(token_id) - .execute(&state.database.pool) - .await - .map_err(|error| ApiError::internal_logged(&request_id, error))? - .rows_affected(); - if changed == 0 { + let changed = state + .tool_calls + .revoke_owner_token(&token_id) + .await + .map_err(|error| ApiError::internal_logged(&request_id, error))?; + if !changed { return Err(ApiError::new( &request_id, StatusCode::NOT_FOUND, @@ -976,6 +1027,7 @@ async fn revoke_token( "The API token was not found or was already revoked.", )); } + state.execution.revoke_owner(&token_id).await; Ok(StatusCode::NO_CONTENT) } @@ -1019,6 +1071,7 @@ async fn require_gateway_token( identity.0.clone(), now, request_id.0.clone(), + &state.background_tasks, ); } Ok(GatewayIdentity { @@ -1285,6 +1338,7 @@ mod tests { use crate::{ AppConfig, database::{Database, DatabaseError, OpenedDatabase}, + tasks::TaskTracker, }; async fn open_database() -> (TempDir, AppConfig, Database) { @@ -1337,6 +1391,7 @@ mod tests { .expect("failure trigger is installed"); let tracker = Arc::new(TokenLastUsedTracker::new()); + let background_tasks = TaskTracker::default(); let unrelated_attempt = Instant::now(); tracker .attempts @@ -1358,6 +1413,7 @@ mod tests { "retry-token".to_owned(), 1_750_000_000, "failed-request".to_owned(), + &background_tasks, ); wait_for_token_write(&tracker).await; @@ -1383,6 +1439,7 @@ mod tests { "retry-token".to_owned(), 1_750_000_001, "retry-request".to_owned(), + &background_tasks, ); wait_for_token_write(&tracker).await; @@ -1393,6 +1450,7 @@ mod tests { .await .expect("last-used timestamp is readable"); assert_eq!(last_used, Some(1_750_000_001)); + background_tasks.shutdown().await; } #[tokio::test] @@ -1410,11 +1468,13 @@ mod tests { .expect("test writer holds the SQLite write lock"); let tracker = Arc::new(TokenLastUsedTracker::new()); + let background_tasks = TaskTracker::default(); tracker.schedule( database.clone(), "guarded-token".to_owned(), 1_750_000_000, "guarded-request".to_owned(), + &background_tasks, ); drop(database); @@ -1442,5 +1502,6 @@ mod tests { .await .expect("background write releases its instance-lock guard"); reopened.pool.close().await; + background_tasks.shutdown().await; } } diff --git a/src/api/approvals.rs b/src/api/approvals.rs new file mode 100644 index 000000000..03bee27ce --- /dev/null +++ b/src/api/approvals.rs @@ -0,0 +1,333 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, Query, State, rejection::JsonRejection, rejection::QueryRejection}, + http::{HeaderMap, StatusCode}, + routing::get, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ + AdminMutation, ApiError, AppState, GatewayAuthentication, RequestId, parse_json, + protocols::approval_error, require_admin, +}; +use crate::approval::{ + ApprovalAdminDetail, ApprovalDecision, ApprovalListQuery, ApprovalRecord, ApprovalStatus, +}; + +pub(super) fn router() -> Router { + Router::new() + .route("/api/v1/approvals", get(list)) + .route("/api/v1/approvals/{id}", get(admin_detail)) + .route( + "/api/v1/approvals/{id}/decision", + axum::routing::post(decide), + ) + .route( + "/api/v1/gateway/approvals/{id}", + get(owner_detail).delete(cancel), + ) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ListQuery { + status: Option, + cursor: Option, + #[serde(default = "default_limit")] + limit: u32, +} + +const fn default_limit() -> u32 { + 50 +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApprovalListResponse { + items: Vec, + next_cursor: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApprovalSummary { + id: String, + status: ApprovalStatus, + revision: i64, + source_id: String, + tool_id: String, + path: String, + source_display_name: Option, + tool_display_name: Option, + actor_kind: crate::actor::ActorKind, + actor_id: String, + actor_name: Option, + actor_label: String, + actor_api_token_id: Option, + actor_token_name: Option, + surface: crate::catalog::RequestSurface, + mode: &'static str, + provenance: crate::catalog::ModeProvenance, + execution_id: String, + call_id: String, + created_at: i64, + updated_at: i64, + expires_at: i64, + decided_at: Option, + started_at: Option, + completed_at: Option, + decision: Option, + failure_code: Option, +} + +impl From for ApprovalSummary { + fn from(record: ApprovalRecord) -> Self { + let actor_label = match record.actor_kind { + crate::actor::ActorKind::ApiToken => record + .actor_name_snapshot + .clone() + .unwrap_or_else(|| "API token".to_owned()), + crate::actor::ActorKind::Admin => format!("Admin {}", record.actor_id), + crate::actor::ActorKind::System => match record.actor_id.as_str() { + "local_cli" => "Local CLI".to_owned(), + _ => "System".to_owned(), + }, + }; + Self { + id: record.id, + status: record.status, + revision: record.revision, + source_id: record.revisions.source_id, + tool_id: record.revisions.tool_id, + path: record.callable_path_snapshot, + source_display_name: record.source_display_name, + tool_display_name: record.tool_display_name, + actor_kind: record.actor_kind, + actor_id: record.actor_id, + actor_name: record.actor_name_snapshot.clone(), + actor_label, + actor_api_token_id: record.actor_api_token_id, + actor_token_name: record.actor_name_snapshot, + surface: record.surface, + mode: "ask", + provenance: record.mode_provenance, + execution_id: record.execution_id, + call_id: record.call_id, + created_at: record.created_at, + updated_at: record.updated_at, + expires_at: record.expires_at, + decided_at: record.decided_at, + started_at: record.execution_started_at, + completed_at: record.completed_at, + decision: record.decision, + failure_code: record.failure_code, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminDetailResponse { + #[serde(flatten)] + summary: ApprovalSummary, + redacted_arguments: Value, + input_schema: Value, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct OwnerDetailResponse { + id: String, + status: ApprovalStatus, + revision: i64, + path: String, + created_at: i64, + updated_at: i64, + expires_at: i64, + failure_code: Option, + result: Option, +} + +async fn list( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + let Query(query) = query.map_err(|_| invalid_query(&request_id))?; + let before_sequence = query + .cursor + .as_deref() + .map(|cursor| decode_cursor(&request_id, cursor)) + .transpose()?; + state + .tool_calls + .expire_approvals() + .await + .map_err(|error| approval_error(&request_id, error))?; + let page = state + .tool_calls + .approvals() + .list_admin(ApprovalListQuery { + before_sequence, + limit: query.limit, + status: query.status, + }) + .await + .map_err(|error| approval_error(&request_id, error))?; + Ok(Json(ApprovalListResponse { + items: page.items.into_iter().map(ApprovalSummary::from).collect(), + next_cursor: page.next_cursor.map(encode_cursor), + })) +} + +async fn admin_detail( + Extension(request_id): Extension, + State(state): State, + headers: HeaderMap, + Path(approval_id): Path, +) -> Result, ApiError> { + require_admin(&request_id, &state, &headers).await?; + state + .tool_calls + .expire_approvals() + .await + .map_err(|error| approval_error(&request_id, error))?; + let detail = state + .tool_calls + .approvals() + .get_admin(&approval_id) + .await + .map_err(|error| approval_error(&request_id, error))? + .ok_or_else(|| approval_error(&request_id, crate::approval::ApprovalError::NotFound))?; + Ok(Json(admin_response(detail))) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct DecisionRequest { + decision: ApprovalDecision, + expected_revision: i64, +} + +async fn decide( + Extension(request_id): Extension, + State(state): State, + AdminMutation(admin_id): AdminMutation, + Path(approval_id): Path, + payload: Result, JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let Json(payload) = parse_json(&request_id, payload)?; + let detail = state + .tool_calls + .decide( + &approval_id, + &request_id.0, + payload.expected_revision, + payload.decision, + admin_id, + ) + .await + .map_err(|error| approval_error(&request_id, error))?; + let status = if detail.record.status.is_terminal() { + StatusCode::OK + } else { + StatusCode::ACCEPTED + }; + Ok((status, Json(admin_response(detail)))) +} + +async fn owner_detail( + Extension(request_id): Extension, + State(state): State, + GatewayAuthentication(identity): GatewayAuthentication, + Path(approval_id): Path, +) -> Result, ApiError> { + state + .tool_calls + .expire_approvals() + .await + .map_err(|error| approval_error(&request_id, error))?; + let detail = state + .tool_calls + .approvals() + .get_for_token(&approval_id, &identity.token_id) + .await + .map_err(|error| approval_error(&request_id, error))? + .ok_or_else(|| approval_error(&request_id, crate::approval::ApprovalError::NotFound))?; + Ok(Json(OwnerDetailResponse { + id: detail.record.id, + status: detail.record.status, + revision: detail.record.revision, + path: detail.record.callable_path_snapshot, + created_at: detail.record.created_at, + updated_at: detail.record.updated_at, + expires_at: detail.record.expires_at, + failure_code: detail.record.failure_code, + result: detail.result, + })) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CancelQuery { + expected_revision: i64, +} + +async fn cancel( + Extension(request_id): Extension, + State(state): State, + GatewayAuthentication(identity): GatewayAuthentication, + Path(approval_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let Query(query) = query.map_err(|_| invalid_query(&request_id))?; + state + .tool_calls + .cancel_approval(&approval_id, &identity.token_id, query.expected_revision) + .await + .map_err(|error| approval_error(&request_id, error))?; + owner_detail( + Extension(request_id), + State(state), + GatewayAuthentication(identity), + Path(approval_id), + ) + .await +} + +fn admin_response(detail: ApprovalAdminDetail) -> AdminDetailResponse { + AdminDetailResponse { + summary: ApprovalSummary::from(detail.record), + redacted_arguments: detail.redacted_arguments, + input_schema: detail.input_schema, + } +} + +fn encode_cursor(sequence: i64) -> String { + URL_SAFE_NO_PAD.encode(sequence.to_be_bytes()) +} + +fn decode_cursor(request_id: &RequestId, cursor: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| invalid_query(request_id))?; + let bytes: [u8; 8] = bytes.try_into().map_err(|_| invalid_query(request_id))?; + let sequence = i64::from_be_bytes(bytes); + if sequence <= 0 { + return Err(invalid_query(request_id)); + } + Ok(sequence) +} + +fn invalid_query(request_id: &RequestId) -> ApiError { + ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_query", + "The approval query is invalid.", + ) +} diff --git a/src/api/openapi.rs b/src/api/openapi.rs index f8a94594b..3fd535896 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -1,33 +1,12 @@ -use std::collections::BTreeMap; - use axum::{ Json, Router, - extract::{DefaultBodyLimit, Extension, Path, Query, State, rejection::JsonRejection}, - http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, - routing::{get, post}, + extract::{DefaultBodyLimit, Extension, State, rejection::JsonRejection}, + routing::post, }; -use reqwest::Method; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; -use url::Url; +use serde::Deserialize; -use super::{ - AdminMutation, ApiError, AppState, RequestId, parse_json, protocols::InvocationAdapterError, - require_admin, require_admin_mutation, -}; -use crate::{ - catalog::{ - ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, - InitialCatalogSnapshot, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, - StoredCredential, ToolBinding, - }, - openapi::{ - CompiledOpenApi, OpenApiBinding, OpenApiCredentialSet, OpenApiError, - OpenApiInvocationError, OpenApiParameterLocation, OpenApiSecurityScheme, - build_protocol_request_with_base, compile_document, - }, - outbound::{HardenedHttpClient, OutboundError, OutboundPolicy, OutboundRequest, parse_url}, -}; +use super::{AdminMutation, ApiError, AppState, RequestId, parse_json, protocols::protocol_error}; +use crate::protocols::{OpenApiPreview, OpenApiSpecInput}; const MAX_SPEC_BYTES: usize = 16 * 1024 * 1024; @@ -35,858 +14,27 @@ pub(super) fn router() -> Router { Router::new() .route("/api/v1/sources/openapi/preview", post(preview)) .layer(DefaultBodyLimit::max(MAX_SPEC_BYTES + 64 * 1024)) - .merge( - Router::new() - .route("/api/v1/sources/{id}/refresh", post(refresh_source)) - .route( - "/api/v1/sources/{id}/credentials", - get(get_credentials) - .put(put_credentials) - .delete(delete_credentials), - ), - ) -} - -#[derive(Clone, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum OpenApiSpecInput { - Inline { content: String }, - Url { url: String }, } #[derive(Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct PreviewRequest { spec: OpenApiSpecInput, #[serde(default)] allow_private_network: bool, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PreviewResponse { - title: String, - description: Option, - tool_count: usize, - tools: Vec, - security_schemes: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PreviewTool { - preferred_name: String, - display_name: String, - description: Option, - intrinsic_mode: crate::catalog::ToolMode, - security: Vec>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PreviewSecurityScheme { - name: String, - credential_type: &'static str, - placement: Option<&'static str>, - supported: bool, - oauth_flows: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct CreateOpenApiSourceRequest { - display_name: String, - preferred_slug: Option, - description: Option, - spec: OpenApiSpecInput, - #[serde(default)] - allow_private_network: bool, - #[serde(default)] - credential: OpenApiCredentialSet, -} - -#[derive(Clone, Deserialize, Serialize)] -#[serde( - tag = "type", - rename_all = "snake_case", - rename_all_fields = "camelCase" -)] -enum StoredLocator { - Inline, - Url { - url: String, - document_base_url: String, - }, -} - -#[derive(Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct StoredOpenApiCredential { - locator: StoredLocator, - credentials: OpenApiCredentialSet, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PutCredentialsRequest { - expected_revision: Option, - credential: OpenApiCredentialSet, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DeleteCredentialsQuery { - expected_revision: i64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct CredentialMetadata { - revision: i64, - configured_schemes: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfiguredCredential { - name: String, - credential_type: &'static str, -} - -async fn get_credentials( - Extension(request_id): Extension, - State(state): State, - headers: HeaderMap, - Path(source_id): Path, -) -> Result, ApiError> { - require_admin(&request_id, &state, &headers).await?; - require_openapi_source(&state, &request_id, &source_id).await?; - credential_metadata(&state, &request_id, &source_id).await -} - async fn preview( Extension(request_id): Extension, + State(state): State, AdminMutation(_admin_id): AdminMutation, payload: Result, JsonRejection>, -) -> Result, ApiError> { +) -> Result, ApiError> { let Json(payload) = parse_json(&request_id, payload)?; - let fetched = fetch_and_compile(&payload.spec, payload.allow_private_network) + let preview = state + .sources + .preview_openapi(&payload.spec, payload.allow_private_network) .await - .map_err(|error| import_error(&request_id, error))?; - Ok(Json(preview_response(fetched.compiled))) -} - -pub(super) async fn create_source( - state: &AppState, - request_id: &RequestId, - admin_id: i64, - payload: CreateOpenApiSourceRequest, -) -> Result { - validate_credentials(request_id, &payload.credential)?; - let fetched = fetch_and_compile(&payload.spec, payload.allow_private_network) - .await - .map_err(|error| import_error(request_id, error))?; - let configuration = source_configuration(&payload.spec, payload.allow_private_network) - .map_err(|error| import_error(request_id, error))?; - let preferred_slug = payload - .preferred_slug - .unwrap_or_else(|| payload.display_name.clone()); - let audit = AuditContext::admin(&request_id.0, admin_id); - let credential = StoredOpenApiCredential { - locator: fetched.locator, - credentials: payload.credential, - }; - let snapshot = initial_catalog_snapshot(&fetched.compiled); - let bindings = staged_bindings(&fetched.compiled); - let (source, _) = state - .catalog - .create_source_with_catalog( - CreateSource { - kind: SourceKind::Openapi, - preferred_slug, - display_name: payload.display_name, - description: payload.description, - configuration, - }, - &credential_payload(&credential) - .map_err(|error| ApiError::internal_logged(request_id, error))?, - snapshot, - bindings, - audit, - ) - .await - .map_err(|error| catalog_error(request_id, error))?; - Ok(source) -} - -async fn refresh_source( - Extension(request_id): Extension, - State(state): State, - headers: HeaderMap, - Path(source_id): Path, -) -> Result, ApiError> { - let admin = require_admin_mutation(&request_id, &state, &headers).await?; - let source = require_openapi_source(&state, &request_id, &source_id).await?; - let stored = stored_credential(&state, &request_id, &source_id).await?; - let allow_private_network = source - .configuration - .get("allowPrivateNetwork") - .and_then(Value::as_bool) - .unwrap_or(false); - let fetched = match &stored.1.locator { - StoredLocator::Inline => { - let document = sqlx::query_scalar::<_, String>( - "SELECT content_json FROM source_artifacts WHERE source_id = ? \ - AND artifact_kind = 'openapi_document' AND stable_key = 'document'", - ) - .bind(&source_id) - .fetch_optional(state.catalog.pool()) - .await - .map_err(|error| ApiError::internal_logged(&request_id, error))? - .ok_or_else(|| { - ApiError::new( - &request_id, - StatusCode::CONFLICT, - "source_artifact_missing", - "The source has no OpenAPI document to refresh.", - ) - })?; - FetchedSpec { - compiled: compile_bytes(document.into_bytes()) - .await - .map_err(|error| import_error(&request_id, error))?, - locator: StoredLocator::Inline, - } - } - StoredLocator::Url { url, .. } => fetch_url(url, allow_private_network) - .await - .map_err(|error| import_error(&request_id, error))?, - }; - let result = import_compiled( - &state, - &source_id, - source.revision, - stored.0, - fetched.compiled, - AuditContext::admin(&request_id.0, admin.id), - ) - .await?; - Ok(Json(result)) -} - -async fn put_credentials( - Extension(request_id): Extension, - State(state): State, - Path(source_id): Path, - AdminMutation(admin_id): AdminMutation, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let Json(payload) = parse_json(&request_id, payload)?; - require_openapi_source(&state, &request_id, &source_id).await?; - validate_credentials(&request_id, &payload.credential)?; - let (revision, mut stored) = stored_credential(&state, &request_id, &source_id).await?; - if payload.expected_revision != Some(revision) { - return Err(revision_conflict(&request_id)); - } - stored.credentials = payload.credential; - state - .catalog - .put_credential( - &source_id, - &credential_payload(&stored) - .map_err(|error| ApiError::internal_logged(&request_id, error))?, - Some(revision), - AuditContext::admin(&request_id.0, admin_id), - ) - .await - .map_err(|error| catalog_error(&request_id, error))?; - credential_metadata(&state, &request_id, &source_id).await -} - -async fn delete_credentials( - Extension(request_id): Extension, - State(state): State, - headers: HeaderMap, - Path(source_id): Path, - Query(query): Query, -) -> Result, ApiError> { - let admin = require_admin_mutation(&request_id, &state, &headers).await?; - require_openapi_source(&state, &request_id, &source_id).await?; - let (revision, mut stored) = stored_credential(&state, &request_id, &source_id).await?; - if query.expected_revision != revision { - return Err(revision_conflict(&request_id)); - } - stored.credentials = OpenApiCredentialSet::default(); - state - .catalog - .put_credential( - &source_id, - &credential_payload(&stored) - .map_err(|error| ApiError::internal_logged(&request_id, error))?, - Some(revision), - AuditContext::admin(&request_id.0, admin.id), - ) - .await - .map_err(|error| catalog_error(&request_id, error))?; - credential_metadata(&state, &request_id, &source_id).await -} - -struct FetchedSpec { - compiled: CompiledOpenApi, - locator: StoredLocator, -} - -#[derive(Debug)] -enum ImportError { - OpenApi(OpenApiError), - Outbound(OutboundError), - TooLarge, - InlineServerRequired, -} - -async fn fetch_and_compile( - spec: &OpenApiSpecInput, - allow_private_network: bool, -) -> Result { - match spec { - OpenApiSpecInput::Inline { content } => { - if content.len() > MAX_SPEC_BYTES { - return Err(ImportError::TooLarge); - } - let compiled = compile_bytes(content.as_bytes().to_vec()).await?; - if compiled - .tools - .iter() - .any(|tool| Url::parse(&tool.binding.server_url).is_err()) - { - return Err(ImportError::InlineServerRequired); - } - Ok(FetchedSpec { - compiled, - locator: StoredLocator::Inline, - }) - } - OpenApiSpecInput::Url { url } => fetch_url(url, allow_private_network).await, - } -} - -async fn fetch_url(url: &str, allow_private_network: bool) -> Result { - let policy = OutboundPolicy { - allow_private_networks: allow_private_network, - max_response_bytes: MAX_SPEC_BYTES, - ..OutboundPolicy::default() - }; - let url = parse_url(url, &policy).map_err(ImportError::Outbound)?; - let response = HardenedHttpClient::new(policy) - .fetch_spec(url.clone(), HeaderMap::new()) - .await - .map_err(ImportError::Outbound)?; - let mut compiled = compile_bytes(response.body).await?; - for tool in &mut compiled.tools { - tool.binding.server_url = response - .final_url - .join(&tool.binding.server_url) - .map_err(|_| { - ImportError::OpenApi(OpenApiError::InvalidDocument("a server URL is invalid")) - })? - .to_string(); - } - Ok(FetchedSpec { - compiled, - locator: StoredLocator::Url { - url: url.to_string(), - document_base_url: response.final_url.to_string(), - }, - }) -} - -async fn compile_bytes(bytes: Vec) -> Result { - tokio::task::spawn_blocking(move || compile_document(&bytes)) - .await - .map_err(|_| { - ImportError::OpenApi(OpenApiError::InvalidDocument("OpenAPI compilation failed")) - })? - .map_err(ImportError::OpenApi) -} - -async fn import_compiled( - state: &AppState, - source_id: &str, - source_revision: i64, - credential_revision: i64, - compiled: CompiledOpenApi, - audit: AuditContext<'_>, -) -> Result { - let request_id = RequestId(audit.request_id().unwrap_or("openapi-import").to_owned()); - let snapshot = catalog_snapshot(&compiled, source_revision, credential_revision); - let bindings = staged_bindings(&compiled); - let result = state - .catalog - .sync_catalog_with_bindings(source_id, snapshot, bindings, audit) - .await - .map_err(|error| catalog_error(&request_id, error))?; - Ok(result) -} - -fn initial_catalog_snapshot(compiled: &CompiledOpenApi) -> InitialCatalogSnapshot { - InitialCatalogSnapshot { - artifacts: staged_artifacts(compiled), - tools: staged_tools(compiled), - } -} - -fn catalog_snapshot( - compiled: &CompiledOpenApi, - source_revision: i64, - credential_revision: i64, -) -> CatalogSnapshot { - CatalogSnapshot { - expected_source_revision: source_revision, - expected_credential_revision: Some(credential_revision), - artifacts: staged_artifacts(compiled), - tools: staged_tools(compiled), - } -} - -fn staged_tools(compiled: &CompiledOpenApi) -> Vec { - compiled - .tools - .iter() - .map(|tool| StagedTool { - stable_key: tool.stable_key.clone(), - preferred_name: tool.preferred_name.clone(), - display_name: tool.display_name.clone(), - description: tool.description.clone(), - input_schema: tool.input_schema.clone(), - output_schema: tool.output_schema.clone(), - input_typescript: None, - output_typescript: None, - typescript_definitions: BTreeMap::new(), - intrinsic_mode: tool.intrinsic_mode, - }) - .collect() -} - -fn staged_bindings(compiled: &CompiledOpenApi) -> Vec { - compiled - .tools - .iter() - .map(|tool| StagedToolBinding { - stable_key: tool.stable_key.clone(), - binding: ToolBinding::OpenapiV1(tool.binding.clone()), - }) - .collect() -} - -fn staged_artifacts(compiled: &CompiledOpenApi) -> Vec { - vec![StagedArtifact { - kind: ArtifactKind::OpenapiDocument, - stable_key: "document".to_owned(), - content: compiled.document.clone(), - }] -} - -fn preview_response(compiled: CompiledOpenApi) -> PreviewResponse { - let tool_count = compiled.tools.len(); - let mut security_schemes = BTreeMap::new(); - for tool in &compiled.tools { - for alternative in &tool.binding.security { - for requirement in &alternative.requirements { - security_schemes - .entry(requirement.scheme_name.clone()) - .or_insert_with(|| preview_security_scheme(requirement)); - } - } - } - PreviewResponse { - title: compiled.title, - description: compiled.description, - tool_count, - tools: compiled - .tools - .into_iter() - .map(|tool| PreviewTool { - preferred_name: tool.preferred_name, - display_name: tool.display_name, - description: tool.description, - intrinsic_mode: tool.intrinsic_mode, - security: tool - .binding - .security - .into_iter() - .map(|alternative| { - alternative - .requirements - .into_iter() - .map(|requirement| requirement.scheme_name) - .collect() - }) - .collect(), - }) - .collect(), - security_schemes: security_schemes.into_values().collect(), - } -} - -fn preview_security_scheme( - requirement: &crate::openapi::OpenApiSecurityRequirement, -) -> PreviewSecurityScheme { - let (credential_type, placement, supported) = match &requirement.scheme { - OpenApiSecurityScheme::ApiKey { location, .. } => ( - "api_key", - Some(match location { - OpenApiParameterLocation::Header => "header", - OpenApiParameterLocation::Query => "query", - OpenApiParameterLocation::Cookie => "cookie", - OpenApiParameterLocation::Path => "path", - }), - *location != OpenApiParameterLocation::Path, - ), - OpenApiSecurityScheme::Http { scheme, .. } if scheme == "bearer" => { - ("bearer", Some("header"), true) - } - OpenApiSecurityScheme::Http { scheme, .. } if scheme == "basic" => { - ("basic", Some("header"), true) - } - OpenApiSecurityScheme::Http { .. } => ("http", Some("header"), false), - OpenApiSecurityScheme::OAuth2 => ("manual_oauth_access_token", Some("header"), true), - OpenApiSecurityScheme::OpenIdConnect { .. } => { - ("manual_oauth_access_token", Some("header"), true) - } - OpenApiSecurityScheme::MutualTls => ("mutual_tls", None, false), - }; - PreviewSecurityScheme { - name: requirement.scheme_name.clone(), - credential_type, - placement, - supported, - oauth_flows: requirement.oauth_flows.clone(), - } -} - -fn source_configuration( - spec: &OpenApiSpecInput, - allow_private_network: bool, -) -> Result, ImportError> { - let spec = match spec { - OpenApiSpecInput::Inline { .. } => json!({ "type": "inline" }), - OpenApiSpecInput::Url { url } => { - let mut display = - Url::parse(url).map_err(|_| ImportError::Outbound(OutboundError::InvalidUrl))?; - display.set_query(None); - json!({ "type": "url", "displayUrl": display.to_string() }) - } - }; - Ok(Map::from_iter([ - ("spec".to_owned(), spec), - ( - "allowPrivateNetwork".to_owned(), - Value::Bool(allow_private_network), - ), - ])) -} - -fn credential_payload( - credential: &StoredOpenApiCredential, -) -> Result { - Ok(CredentialPayload { - schema_version: 1, - payload: serde_json::to_value(credential)?, - }) -} - -async fn require_openapi_source( - state: &AppState, - request_id: &RequestId, - source_id: &str, -) -> Result { - let source = state - .catalog - .source(source_id) - .await - .map_err(|error| catalog_error(request_id, error))?; - if source.kind != SourceKind::Openapi { - return Err(ApiError::new( - request_id, - StatusCode::BAD_REQUEST, - "unsupported_source_kind", - "Only OpenAPI sources can use this endpoint.", - )); - } - Ok(source) -} - -async fn stored_credential( - state: &AppState, - request_id: &RequestId, - source_id: &str, -) -> Result<(i64, StoredOpenApiCredential), ApiError> { - let stored = state - .catalog - .credential(source_id) - .await - .map_err(|error| catalog_error(request_id, error))? - .ok_or_else(|| { - ApiError::new( - request_id, - StatusCode::CONFLICT, - "source_credentials_missing", - "The source credential state is missing.", - ) - })?; - if stored.credential.schema_version != 1 { - return Err(ApiError::new( - request_id, - StatusCode::CONFLICT, - "unsupported_credential_schema", - "The stored OpenAPI credential schema is not supported.", - )); - } - let credential = serde_json::from_value(stored.credential.payload) - .map_err(|error| ApiError::internal_logged(request_id, error))?; - Ok((stored.revision, credential)) -} - -async fn credential_metadata( - state: &AppState, - request_id: &RequestId, - source_id: &str, -) -> Result, ApiError> { - let (revision, stored) = stored_credential(state, request_id, source_id).await?; - Ok(Json(CredentialMetadata { - revision, - configured_schemes: stored - .credentials - .schemes - .into_iter() - .map(|(name, credential)| ConfiguredCredential { - name, - credential_type: credential.credential_type(), - }) - .collect(), - })) -} - -fn validate_credentials( - request_id: &RequestId, - credentials: &OpenApiCredentialSet, -) -> Result<(), ApiError> { - credentials.validate().map_err(|_| { - ApiError::new( - request_id, - StatusCode::BAD_REQUEST, - "invalid_credentials", - "The static credential configuration is invalid.", - ) - }) -} - -pub(super) struct OpenApiInvocationPlan { - pub request: OutboundRequest, - pub policy: OutboundPolicy, -} - -pub(super) fn invocation_plan( - binding: &OpenApiBinding, - source_configuration: &Map, - stored: Option<&StoredCredential>, - arguments: &Value, -) -> Result { - let stored = stored.ok_or(InvocationAdapterError { - code: "source_credentials_missing", - message: "The source credential state is missing.", - })?; - if stored.credential.schema_version != 1 { - return Err(InvocationAdapterError { - code: "unsupported_credential_schema", - message: "The stored OpenAPI credential schema is not supported.", - }); - } - let credential: StoredOpenApiCredential = - serde_json::from_value(stored.credential.payload.clone()).map_err(|_| { - InvocationAdapterError { - code: "invalid_source_credentials", - message: "The source credential state is invalid.", - } - })?; - let document_base_url = match &credential.locator { - StoredLocator::Inline => None, - StoredLocator::Url { - document_base_url, .. - } => Some( - Url::parse(document_base_url).map_err(|_| InvocationAdapterError { - code: "invalid_openapi_server", - message: "The OpenAPI operation has no usable server URL.", - })?, - ), - }; - let protocol_request = build_protocol_request_with_base( - binding, - arguments, - &credential.credentials, - document_base_url.as_ref(), - ) - .map_err(invocation_adapter_error)?; - let method = Method::from_bytes(protocol_request.method.as_bytes()).map_err(|_| { - InvocationAdapterError { - code: "invalid_tool_arguments", - message: "The tool arguments are invalid.", - } - })?; - let mut request = OutboundRequest::new(method, protocol_request.url); - for (name, value) in protocol_request.headers { - let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| InvocationAdapterError { - code: "forbidden_tool_header", - message: "Tool arguments cannot set a protected HTTP header.", - })?; - let value = HeaderValue::from_str(&value).map_err(|_| InvocationAdapterError { - code: "invalid_tool_arguments", - message: "The tool arguments are invalid.", - })?; - request.headers.insert(name, value); - } - request.body = protocol_request.body; - Ok(OpenApiInvocationPlan { - request, - policy: OutboundPolicy { - allow_private_networks: source_configuration - .get("allowPrivateNetwork") - .and_then(Value::as_bool) - .unwrap_or(false), - ..OutboundPolicy::default() - }, - }) -} - -fn invocation_adapter_error(error: OpenApiInvocationError) -> InvocationAdapterError { - match error { - OpenApiInvocationError::InvalidArguments | OpenApiInvocationError::InvalidArgument(_) => { - InvocationAdapterError { - code: "invalid_tool_arguments", - message: "The tool arguments are invalid.", - } - } - OpenApiInvocationError::MissingArgument(_) => InvocationAdapterError { - code: "missing_tool_argument", - message: "A required tool argument is missing.", - }, - OpenApiInvocationError::UnsatisfiedSecurity => InvocationAdapterError { - code: "missing_source_credentials", - message: "No configured credential satisfies this operation.", - }, - OpenApiInvocationError::InvalidCredential(_) => InvocationAdapterError { - code: "unsupported_authentication", - message: "This operation requires an authentication method that is not configured.", - }, - OpenApiInvocationError::InvalidCredentialConfiguration => InvocationAdapterError { - code: "invalid_source_credentials", - message: "The source credential state is invalid.", - }, - OpenApiInvocationError::InvalidHeader(_) => InvocationAdapterError { - code: "forbidden_tool_header", - message: "Tool arguments cannot set a protected HTTP header.", - }, - OpenApiInvocationError::InvalidUrl => InvocationAdapterError { - code: "invalid_openapi_server", - message: "The OpenAPI operation has no usable server URL.", - }, - } -} - -fn import_error(request_id: &RequestId, error: ImportError) -> ApiError { - match error { - ImportError::OpenApi(error) => ApiError::new( - request_id, - StatusCode::BAD_REQUEST, - openapi_error_code(&error), - error.to_string(), - ), - ImportError::Outbound(error) => outbound_error(request_id, error), - ImportError::TooLarge => ApiError::new( - request_id, - StatusCode::PAYLOAD_TOO_LARGE, - "openapi_document_too_large", - "The OpenAPI document exceeds the allowed size.", - ), - ImportError::InlineServerRequired => ApiError::new( - request_id, - StatusCode::BAD_REQUEST, - "inline_openapi_server_required", - "Inline OpenAPI documents must define an absolute HTTP or HTTPS server URL.", - ), - } -} - -fn openapi_error_code(error: &OpenApiError) -> &'static str { - match error { - OpenApiError::Parse => "invalid_openapi_document", - OpenApiError::UnsupportedVersion => "unsupported_openapi_version", - OpenApiError::ExternalReference(_) => "external_reference_unsupported", - OpenApiError::ReferenceNotFound(_) => "openapi_reference_not_found", - OpenApiError::ReferenceCycle(_) => "openapi_reference_cycle", - OpenApiError::ReferenceDepth => "openapi_reference_depth", - OpenApiError::LimitExceeded { code } => code, - OpenApiError::InvalidDocument(_) | OpenApiError::InvalidOperation { .. } => { - "invalid_openapi_document" - } - } -} - -fn outbound_error(request_id: &RequestId, error: OutboundError) -> ApiError { - let status = match error { - OutboundError::PrivateAddress | OutboundError::ForbiddenAddress => StatusCode::FORBIDDEN, - OutboundError::ResponseBodyTooLarge - | OutboundError::ResponseHeadersTooLarge - | OutboundError::RequestBodyTooLarge - | OutboundError::RequestHeadersTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - OutboundError::Timeout => StatusCode::GATEWAY_TIMEOUT, - OutboundError::Connection - | OutboundError::Request - | OutboundError::DnsResolution - | OutboundError::UpstreamStatus { .. } => StatusCode::BAD_GATEWAY, - _ => StatusCode::BAD_REQUEST, - }; - ApiError::new( - request_id, - status, - error.code(), - "The upstream request could not be completed safely.", - ) -} - -fn catalog_error(request_id: &RequestId, error: CatalogError) -> ApiError { - match error { - CatalogError::Validation { code, message } => { - ApiError::new(request_id, StatusCode::BAD_REQUEST, code, message) - } - CatalogError::NotFound { entity: "source" } => ApiError::new( - request_id, - StatusCode::NOT_FOUND, - "source_not_found", - "The requested source does not exist.", - ), - CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => ApiError::new( - request_id, - StatusCode::NOT_FOUND, - "tool_not_found", - "The requested tool does not exist.", - ), - CatalogError::ToolDisabled { .. } => ApiError::new( - request_id, - StatusCode::FORBIDDEN, - "tool_disabled", - "The requested tool is disabled.", - ), - CatalogError::RevisionConflict { .. } => revision_conflict(request_id), - error => ApiError::internal_logged(request_id, error), - } -} - -fn revision_conflict(request_id: &RequestId) -> ApiError { - ApiError::new( - request_id, - StatusCode::CONFLICT, - "revision_conflict", - "The source changed. Refresh and retry the update.", - ) + .map_err(|error| protocol_error(&request_id, error))?; + Ok(Json(preview)) } diff --git a/src/api/protocols.rs b/src/api/protocols.rs index 861ae0315..a1542905f 100644 --- a/src/api/protocols.rs +++ b/src/api/protocols.rs @@ -1,40 +1,211 @@ -use std::{collections::BTreeMap, time::Instant}; - use axum::{ Json, Router, - extract::{DefaultBodyLimit, Extension, State, rejection::JsonRejection}, - http::{HeaderMap, StatusCode, header}, - routing::post, + body::Body, + extract::{DefaultBodyLimit, Extension, Path, Query, State, rejection::JsonRejection}, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, }; -use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; +use serde_json::{Map, Value}; use super::{ - AdminMutation, ApiError, AppState, GatewayAuthentication, RequestId, openapi, parse_json, + AdminAuthentication, AdminMutation, ApiError, AppState, GatewayAuthentication, RequestId, + parse_json, }; use crate::{ - catalog::{ - CatalogError, InvocationLease, InvocationLookup, InvocationRevisionToken, NewRequestLog, - RequestOutcome, RequestSurface, SourceKind, ToolBinding, + actor::ToolActor, + approval::ApprovalError, + catalog::{AuditContext, CatalogError, RequestSurface, SourceKind}, + execution::{ExecuteCodeRequest, ExecutionServiceError}, + invocation::{ + GatewayInvokeError, GatewayInvokeResponse, ToolCall, ToolCallError, ToolCallSubmission, + gateway_idempotency_key_is_valid, }, - outbound::{HardenedHttpClient, OutboundError}, - unix_timestamp, + outbound::OutboundError, + protocols::{CredentialMetadata, ProtocolError, ProtocolErrorCategory}, + runtime::RuntimeFailure, }; const MAX_SOURCE_BODY_BYTES: usize = 16 * 1024 * 1024 + 64 * 1024; -const MAX_ARGUMENT_BYTES: usize = 8 * 1024 * 1024; -const MAX_INVOKE_BODY_BYTES: usize = MAX_ARGUMENT_BYTES + 64 * 1024; +const MAX_INVOKE_BODY_BYTES: usize = crate::invocation::MAX_ARGUMENT_BYTES + 64 * 1024; +const MAX_EXECUTE_BODY_BYTES: usize = crate::runtime::MAX_SOURCE_BYTES + 64 * 1024; +const DEFAULT_EXECUTION_TIMEOUT_MILLIS: u64 = 30_000; +const MAX_EXECUTION_TIMEOUT_MILLIS: u64 = 300_000; +const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; pub(super) fn router() -> Router { Router::new() .route("/api/v1/sources", post(create_source)) .layer(DefaultBodyLimit::max(MAX_SOURCE_BODY_BYTES)) + .merge( + Router::new() + .route("/api/v1/sources/{id}/refresh", post(refresh_source)) + .route( + "/api/v1/sources/{id}/credentials", + get(get_credentials) + .put(put_credentials) + .delete(delete_credentials), + ), + ) .merge( Router::new() .route("/api/v1/gateway/tools/invoke", post(invoke)) .layer(DefaultBodyLimit::max(MAX_INVOKE_BODY_BYTES)), ) + .merge( + Router::new() + .route("/api/v1/gateway/execute", post(execute)) + .layer(DefaultBodyLimit::max(MAX_EXECUTE_BODY_BYTES)), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExecuteRequest { + code: String, + timeout_ms: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ExecuteResponse { + execution_id: String, + result: Value, + emits: Vec, + console: Vec, + calls: Vec, +} + +async fn execute( + Extension(request_id): Extension, + State(state): State, + GatewayAuthentication(identity): GatewayAuthentication, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + if headers.contains_key(IDEMPOTENCY_KEY_HEADER) { + return Err(ApiError::new( + &request_id, + StatusCode::BAD_REQUEST, + "idempotency_not_supported", + "Idempotency-Key is not supported for whole TypeScript executions yet.", + )); + } + let Json(payload) = match parse_json(&request_id, payload) { + Ok(payload) => payload, + Err(error) => { + state.tool_calls.record_rejected_request( + &request_id.0, + &identity.token_id, + RequestSurface::Gateway, + "executor.execute", + error.code, + ); + return Err(error); + } + }; + let timeout_ms = payload + .timeout_ms + .unwrap_or(DEFAULT_EXECUTION_TIMEOUT_MILLIS); + let output = state + .execution + .execute(ExecuteCodeRequest { + request_id: request_id.0.clone(), + actor: ToolActor::api_token(identity.token_id, Some(identity.token_name)), + surface: RequestSurface::Gateway, + code: payload.code, + timeout: std::time::Duration::from_millis(timeout_ms), + }) + .await + .map_err(|error| execution_error(&request_id, error))?; + Ok(Json(ExecuteResponse { + execution_id: output.execution_id, + result: output.result, + emits: output.emits, + console: output.console, + calls: output.calls, + })) +} + +fn execution_error(request_id: &RequestId, error: ExecutionServiceError) -> ApiError { + match error { + ExecutionServiceError::SourceTooLarge => ApiError::new( + request_id, + StatusCode::PAYLOAD_TOO_LARGE, + "source_too_large", + "TypeScript source exceeds 1 MiB.", + ), + ExecutionServiceError::InvalidTimeout => ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_timeout", + format!( + "Execution timeout must be between 1 and {MAX_EXECUTION_TIMEOUT_MILLIS} milliseconds." + ), + ), + ExecutionServiceError::ShuttingDown => ApiError::new( + request_id, + StatusCode::SERVICE_UNAVAILABLE, + "runtime_shutting_down", + "The TypeScript runtime is shutting down.", + ), + ExecutionServiceError::OwnerRevoked => { + ApiError::unauthorized(request_id, "The API token is no longer active.") + } + ExecutionServiceError::ActorFailed => { + tracing::error!(request_id = request_id.0, "runtime execution task failed"); + ApiError::internal(request_id) + } + ExecutionServiceError::Runtime(failure) => runtime_error(request_id, failure), + } +} + +fn runtime_error(request_id: &RequestId, failure: RuntimeFailure) -> ApiError { + let status = match failure.code.as_str() { + "source_too_large" | "result_too_large" => StatusCode::PAYLOAD_TOO_LARGE, + "runtime_busy" => StatusCode::TOO_MANY_REQUESTS, + "execution_timeout" => StatusCode::REQUEST_TIMEOUT, + "execution_cancelled" => StatusCode::CONFLICT, + "invalid_timeout" + | "typescript_invalid" + | "typescript_unsupported" + | "transformed_source_too_large" + | "execution_failed" + | "result_not_json" + | "argument_too_large" + | "tool_call_limit_exceeded" + | "tool_path_invalid" => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + let code: &'static str = match failure.code.as_str() { + "source_too_large" => "source_too_large", + "result_too_large" => "result_too_large", + "runtime_busy" => "runtime_busy", + "execution_timeout" => "execution_timeout", + "execution_cancelled" => "execution_cancelled", + "invalid_timeout" => "invalid_timeout", + "typescript_invalid" => "typescript_invalid", + "typescript_unsupported" => "typescript_unsupported", + "transformed_source_too_large" => "transformed_source_too_large", + "execution_failed" => "execution_failed", + "result_not_json" => "result_not_json", + "argument_too_large" => "argument_too_large", + "tool_call_limit_exceeded" => "tool_call_limit_exceeded", + "tool_path_invalid" => "tool_path_invalid", + _ => "runtime_failed", + }; + let message = if failure.internal { + "The TypeScript execution could not be completed safely.".to_owned() + } else { + failure.message + }; + let error = ApiError::new(request_id, status, code, message); + if code == "runtime_busy" { + error.with_retry_after(1) + } else { + error + } } #[derive(Deserialize)] @@ -52,30 +223,107 @@ async fn create_source( payload: Result, JsonRejection>, ) -> Result<(StatusCode, Json), ApiError> { let Json(payload) = parse_json(&request_id, payload)?; - match payload.kind { - SourceKind::Openapi => { - let request = - serde_json::from_value(Value::Object(payload.protocol)).map_err(|_| { - ApiError::new( - &request_id, - StatusCode::BAD_REQUEST, - "invalid_json", - "The request body must be valid JSON with the expected fields.", - ) - })?; - let source = openapi::create_source(&state, &request_id, admin_id, request).await?; - Ok((StatusCode::CREATED, Json(source))) - } - SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => Err(ApiError::new( - &request_id, - StatusCode::BAD_REQUEST, - "unsupported_source_kind", - "This source protocol is not supported yet.", - )), - } + let source = state + .sources + .create( + payload.kind, + Value::Object(payload.protocol), + AuditContext::admin(&request_id.0, admin_id), + ) + .await + .map_err(|error| protocol_error(&request_id, error))?; + Ok((StatusCode::CREATED, Json(source))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PutCredentialsRequest { + expected_revision: i64, + credential: Value, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DeleteCredentialsQuery { + expected_revision: i64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RefreshSourceRequest {} + +async fn refresh_source( + Extension(request_id): Extension, + State(state): State, + AdminMutation(admin_id): AdminMutation, + Path(source_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let Json(_payload) = parse_json(&request_id, payload)?; + let refreshed = state + .sources + .refresh(&source_id, AuditContext::admin(&request_id.0, admin_id)) + .await + .map_err(|error| protocol_error(&request_id, error))?; + Ok(Json(refreshed)) +} + +async fn get_credentials( + Extension(request_id): Extension, + State(state): State, + _admin: AdminAuthentication, + Path(source_id): Path, +) -> Result, ApiError> { + let metadata = state + .sources + .credential_metadata(&source_id) + .await + .map_err(|error| protocol_error(&request_id, error))?; + Ok(Json(metadata)) +} + +async fn put_credentials( + Extension(request_id): Extension, + State(state): State, + AdminMutation(admin_id): AdminMutation, + Path(source_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let Json(payload) = parse_json(&request_id, payload)?; + let metadata = state + .sources + .replace_credentials( + &source_id, + payload.expected_revision, + payload.credential, + AuditContext::admin(&request_id.0, admin_id), + ) + .await + .map_err(|error| protocol_error(&request_id, error))?; + Ok(Json(metadata)) +} + +async fn delete_credentials( + Extension(request_id): Extension, + State(state): State, + AdminMutation(admin_id): AdminMutation, + Path(source_id): Path, + Query(query): Query, +) -> Result, ApiError> { + let metadata = state + .sources + .clear_credentials( + &source_id, + query.expected_revision, + AuditContext::admin(&request_id.0, admin_id), + ) + .await + .map_err(|error| protocol_error(&request_id, error))?; + Ok(Json(metadata)) } #[derive(Deserialize)] +#[serde(deny_unknown_fields)] struct InvokeRequest { path: String, #[serde(default = "empty_object")] @@ -86,344 +334,272 @@ fn empty_object() -> Value { Value::Object(Map::new()) } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct InvokeResponse { - ok: bool, - data: Option, - error: Option, - http: InvokeHttp, +fn parse_idempotency_key( + request_id: &RequestId, + headers: &HeaderMap, +) -> Result, ApiError> { + let mut values = headers.get_all(IDEMPOTENCY_KEY_HEADER).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(invalid_idempotency_key(request_id)); + } + let value = value + .to_str() + .map_err(|_| invalid_idempotency_key(request_id))?; + if !gateway_idempotency_key_is_valid(value) { + return Err(invalid_idempotency_key(request_id)); + } + Ok(Some(value.to_owned())) } -#[derive(Serialize)] -struct InvokeError { - code: &'static str, - message: &'static str, +fn invalid_idempotency_key(request_id: &RequestId) -> ApiError { + ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_idempotency_key", + "Idempotency-Key must contain between 1 and 255 visible ASCII bytes.", + ) } #[derive(Serialize)] -struct InvokeHttp { - status: u16, - headers: BTreeMap, - truncated: bool, +#[serde(rename_all = "camelCase")] +struct ApprovalRequiredResponse { + status: &'static str, + approval: PendingApprovalResponse, } -pub(super) struct InvocationAdapterError { - pub code: &'static str, - pub message: &'static str, +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PendingApprovalResponse { + id: String, + status: crate::approval::ApprovalStatus, + revision: i64, + path: String, + created_at: i64, + expires_at: i64, + status_url: String, } async fn invoke( Extension(request_id): Extension, State(state): State, GatewayAuthentication(identity): GatewayAuthentication, + headers: HeaderMap, payload: Result, JsonRejection>, -) -> Result, ApiError> { - let started = Instant::now(); +) -> Result { + let idempotency_key = parse_idempotency_key(&request_id, &headers)?; let Json(payload) = match parse_json(&request_id, payload) { Ok(payload) => payload, Err(error) => { - record_invocation_attempt( - &state, - &request_id, + state.tool_calls.record_rejected_request( + &request_id.0, &identity.token_id, - None, - None, + RequestSurface::Gateway, "tools.invoke", - started, - RequestOutcome::Failed, - Some(error.code), + error.code, ); return Err(error); } }; - if serde_json::to_vec(&payload.arguments) - .is_ok_and(|encoded| encoded.len() > MAX_ARGUMENT_BYTES) - { - record_invocation_attempt( - &state, - &request_id, - &identity.token_id, - None, - None, - &payload.path, - started, - RequestOutcome::Failed, - Some("arguments_too_large"), - ); - return Err(ApiError::new( - &request_id, - StatusCode::PAYLOAD_TOO_LARGE, - "arguments_too_large", - "Tool arguments exceed the allowed size.", - )); - } - - let lease = match state.catalog.prepare_invocation(&payload.path).await { - Ok(lease) => lease, - Err(error) => { - record_invocation_attempt( - &state, - &request_id, - &identity.token_id, - None, - None, - &payload.path, - started, - if matches!(error, CatalogError::ToolDisabled { .. }) { - RequestOutcome::Denied - } else { - RequestOutcome::Failed - }, - Some(catalog_log_code(&error)), - ); - return Err(catalog_error(&request_id, error)); - } - }; - let plan = match dispatch(&lease, &payload.arguments) { - Ok(plan) => plan, - Err(error) => { - record_invocation( - &state, - &request_id, - &identity.token_id, - &lease.lookup, - started, - RequestOutcome::Failed, - Some(error.code), - ); - return Err(ApiError::new( - &request_id, - StatusCode::BAD_REQUEST, - error.code, - error.message, - )); - } + let call = ToolCall { + request_id: request_id.0.clone(), + actor: ToolActor::api_token(identity.token_id, Some(identity.token_name)), + surface: RequestSurface::Gateway, + execution_id: request_id.0.clone(), + call_id: "gateway".to_owned(), + worker_generation: 0, + path: payload.path, + arguments: payload.arguments, }; - if !lease.arguments_are_valid(&payload.arguments) { - record_invocation( - &state, - &request_id, - &identity.token_id, - &lease.lookup, - started, - RequestOutcome::Failed, - Some("invalid_tool_arguments"), - ); - return Err(ApiError::new( - &request_id, - StatusCode::BAD_REQUEST, - "invalid_tool_arguments", - "The tool arguments do not match the imported input schema.", - )); - } - if lease.lookup.requires_approval { - let pending = PendingApproval { - revisions: lease.revisions.clone(), - }; - record_invocation( - &state, - &request_id, - &identity.token_id, - &lease.lookup, - started, - RequestOutcome::PendingApproval, - Some("approval_required"), - ); - return Err(pending.into_api_error(&request_id)); + if let Some(idempotency_key) = idempotency_key { + let response = state + .tool_calls + .submit_gateway_idempotent(call, &idempotency_key) + .await + .map_err(|error| gateway_invoke_error(&request_id, error))?; + return exact_gateway_response(&request_id, response); } - - let response = match HardenedHttpClient::new(plan.policy) - .execute(plan.request) + let submission = state + .tool_calls + .submit(call) .await - { - Ok(response) => { - let succeeded = response.status.is_success(); - let data = response_data(&response.headers, &response.body); - record_invocation( - &state, - &request_id, - &identity.token_id, - &lease.lookup, - started, - if succeeded { - RequestOutcome::Succeeded - } else { - RequestOutcome::Failed - }, - (!succeeded).then_some("upstream_http_error"), - ); - InvokeResponse { - ok: succeeded, - data: succeeded.then_some(data), - error: (!succeeded).then_some(InvokeError { - code: "upstream_http_error", - message: "The upstream API returned an error response.", - }), - http: InvokeHttp { - status: response.status.as_u16(), - headers: safe_response_headers(&response.headers), - truncated: false, + .map_err(|error| tool_call_error(&request_id, error))?; + match submission { + ToolCallSubmission::Completed(result) => Ok(Json(result).into_response()), + ToolCallSubmission::ApprovalRequired(approval) => Ok(( + StatusCode::ACCEPTED, + Json(ApprovalRequiredResponse { + status: "approval_required", + approval: PendingApprovalResponse { + status_url: format!("/api/v1/gateway/approvals/{}", approval.id), + id: approval.id.clone(), + status: approval.status, + revision: approval.revision, + path: approval.callable_path_snapshot.clone(), + created_at: approval.created_at, + expires_at: approval.expires_at, }, - } - } - Err(error) => { - record_invocation( - &state, - &request_id, - &identity.token_id, - &lease.lookup, - started, - RequestOutcome::Failed, - Some(error.code()), - ); - return Err(outbound_error(&request_id, error)); - } - }; - drop(lease); - Ok(Json(response)) -} - -fn dispatch( - lease: &InvocationLease, - arguments: &Value, -) -> Result { - match &lease.binding { - ToolBinding::OpenapiV1(binding) => openapi::invocation_plan( - binding, - &lease.source_configuration, - lease.credential.as_ref(), - arguments, - ), + }), + ) + .into_response()), } } -struct PendingApproval { - revisions: InvocationRevisionToken, +fn exact_gateway_response( + request_id: &RequestId, + output: GatewayInvokeResponse, +) -> Result { + let mut response = Response::builder().status(output.response.status); + for (name, value) in output.response.headers { + let name = HeaderName::try_from(name).map_err(|_| ApiError::internal(request_id))?; + let value = HeaderValue::try_from(value).map_err(|_| ApiError::internal(request_id))?; + response = response.header(name, value); + } + if output.replayed { + response = response.header("idempotency-replayed", "true"); + } + response + .body(Body::from(output.response.body)) + .map_err(|_| ApiError::internal(request_id)) } -impl PendingApproval { - fn into_api_error(self, request_id: &RequestId) -> ApiError { - tracing::debug!( - request_id = %request_id.0, - source_id = %self.revisions.source_id, - tool_id = %self.revisions.tool_id, - source_revision = self.revisions.source_revision, - catalog_revision = self.revisions.catalog_revision, - tool_revision = self.revisions.tool_revision, - binding_revision = self.revisions.binding_revision, - credential_revision = self.revisions.credential_revision, - "invocation requires approval against a catalog revision token" - ); - ApiError::new( +fn gateway_invoke_error(request_id: &RequestId, error: GatewayInvokeError) -> ApiError { + match error { + GatewayInvokeError::ToolCall(error) => tool_call_error(request_id, error), + GatewayInvokeError::InvalidKey => invalid_idempotency_key(request_id), + GatewayInvokeError::Capacity => ApiError::new( + request_id, + StatusCode::TOO_MANY_REQUESTS, + "idempotency_capacity", + "Gateway idempotency capacity has been reached. Retry later.", + ) + .with_retry_after(1), + GatewayInvokeError::KeyMismatch => ApiError::new( + request_id, + StatusCode::CONFLICT, + "idempotency_key_mismatch", + "This Idempotency-Key was already used for a different invocation.", + ), + GatewayInvokeError::InProgress => ApiError::new( request_id, StatusCode::CONFLICT, - "approval_required", - "This tool requires interactive approval before it can run.", + "idempotency_in_progress", + "The invocation for this Idempotency-Key is still in progress.", ) + .with_retry_after(1), + GatewayInvokeError::OutcomeUnknown => ApiError::new( + request_id, + StatusCode::CONFLICT, + "idempotency_outcome_unknown", + "The invocation may have reached the upstream service, so it will not be retried.", + ), + GatewayInvokeError::Idempotency(error) => ApiError::internal_logged(request_id, error), } } -fn response_data(headers: &HeaderMap, body: &[u8]) -> Value { - let content_type = headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - if (content_type.contains("/json") || content_type.contains("+json")) - && let Ok(value) = serde_json::from_slice(body) - { - value - } else if let Ok(value) = std::str::from_utf8(body) { - Value::String(value.to_owned()) - } else { - json!({ "encoding": "base64", "data": STANDARD.encode(body) }) - } -} - -fn safe_response_headers(headers: &HeaderMap) -> BTreeMap { - [ - header::CONTENT_TYPE, - header::CONTENT_LENGTH, - header::RETRY_AFTER, - ] - .into_iter() - .filter_map(|name| { - headers - .get(&name) - .and_then(|value| value.to_str().ok()) - .map(|value| (name.as_str().to_owned(), value.to_owned())) - }) - .collect() -} - -fn record_invocation( - state: &AppState, - request_id: &RequestId, - token_id: &str, - lookup: &InvocationLookup, - started: Instant, - outcome: RequestOutcome, - error_code: Option<&str>, -) { - record_invocation_attempt( - state, - request_id, - token_id, - Some(lookup.source_id.clone()), - Some(lookup.tool_id.clone()), - &lookup.callable_path, - started, - outcome, - error_code, - ); -} - -#[allow(clippy::too_many_arguments)] -fn record_invocation_attempt( - state: &AppState, - request_id: &RequestId, - token_id: &str, - source_id: Option, - tool_id: Option, - path: &str, - started: Instant, - outcome: RequestOutcome, - error_code: Option<&str>, -) { - let mut path_snapshot = if path.starts_with("tools.") { - path.to_owned() - } else { - format!("tools.{path}") - }; - if path_snapshot.len() > 512 || path_snapshot.contains('\0') { - path_snapshot = "tools.invoke".to_owned(); +pub(super) fn tool_call_error(request_id: &RequestId, error: ToolCallError) -> ApiError { + match error { + ToolCallError::Approval(error) => approval_error(request_id, error), + ToolCallError::Catalog(error) => catalog_error(request_id, error), + ToolCallError::Adapter { code, message } => { + ApiError::new(request_id, StatusCode::BAD_REQUEST, code, message) + } + ToolCallError::ArgumentsTooLarge => ApiError::new( + request_id, + StatusCode::PAYLOAD_TOO_LARGE, + "arguments_too_large", + "Tool arguments exceed the allowed size.", + ), + ToolCallError::InvalidArguments => ApiError::new( + request_id, + StatusCode::BAD_REQUEST, + "invalid_tool_arguments", + "The tool arguments do not match the imported input schema.", + ), + ToolCallError::Protocol(error) => protocol_error(request_id, error), + ToolCallError::Stale => ApiError::new( + request_id, + StatusCode::CONFLICT, + "invocation_stale", + "The tool changed before invocation. Retry with the current catalog.", + ), + ToolCallError::Outbound(error) => outbound_error(request_id, error), + ToolCallError::ResultTooLarge => ApiError::new( + request_id, + StatusCode::PAYLOAD_TOO_LARGE, + "result_too_large", + "The upstream tool result exceeds the allowed size.", + ), } - state.request_logs.try_record(NewRequestLog { - request_id: request_id.0.clone(), - actor_api_token_id: Some(token_id.to_owned()), - surface: RequestSurface::Gateway, - source_id, - tool_id, - path_snapshot: Some(path_snapshot), - outcome, - error_code: error_code.map(str::to_owned), - duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), - approval_id: None, - created_at: unix_timestamp(), - }); } -fn catalog_log_code(error: &CatalogError) -> &'static str { +pub(super) fn approval_error(request_id: &RequestId, error: ApprovalError) -> ApiError { match error { - CatalogError::Validation { code, .. } => code, - CatalogError::NotFound { entity: "source" } => "source_not_found", - CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => "tool_not_found", - CatalogError::ToolDisabled { .. } => "tool_disabled", - CatalogError::RevisionConflict { .. } => "revision_conflict", - CatalogError::Database(_) - | CatalogError::Crypto(_) - | CatalogError::Json(_) - | CatalogError::CorruptData(_) => "internal_error", + ApprovalError::NotFound => ApiError::new( + request_id, + StatusCode::NOT_FOUND, + "approval_not_found", + "The requested approval does not exist.", + ), + ApprovalError::Expired => ApiError::new( + request_id, + StatusCode::GONE, + "approval_expired", + "The approval has expired.", + ), + ApprovalError::RevisionConflict { .. } | ApprovalError::DecisionConflict => ApiError::new( + request_id, + StatusCode::CONFLICT, + "revision_conflict", + "The approval changed. Refresh it and retry.", + ), + ApprovalError::InvalidTransition { .. } => ApiError::new( + request_id, + StatusCode::CONFLICT, + "approval_not_cancelable", + "The approval can no longer be canceled or changed.", + ), + ApprovalError::OwnerTokenInactive => ApiError::unauthorized( + request_id, + "The API token that created this approval is no longer active.", + ), + ApprovalError::Capacity { .. } => ApiError::new( + request_id, + StatusCode::TOO_MANY_REQUESTS, + "approval_capacity", + "Too many approvals are active. Resolve an existing approval and retry.", + ) + .with_retry_after(1), + ApprovalError::CorrelationConflict => ApiError::new( + request_id, + StatusCode::CONFLICT, + "duplicate_tool_call", + "The execution call conflicts with an existing approval.", + ), + ApprovalError::CorrelationRetired => ApiError::new( + request_id, + StatusCode::CONFLICT, + "approval_stale", + "The correlated approval is no longer retained.", + ), + ApprovalError::WorkerGenerationConflict => ApiError::new( + request_id, + StatusCode::CONFLICT, + "approval_stale", + "The approval belongs to an older execution generation.", + ), + ApprovalError::PayloadTooLarge { .. } => ApiError::new( + request_id, + StatusCode::PAYLOAD_TOO_LARGE, + "approval_payload_too_large", + "The approval payload exceeds the allowed size.", + ), + ApprovalError::Validation { code, message } => { + ApiError::new(request_id, StatusCode::BAD_REQUEST, code, message) + } + error => ApiError::internal_logged(request_id, error), } } @@ -460,7 +636,35 @@ fn catalog_error(request_id: &RequestId, error: CatalogError) -> ApiError { } } -fn outbound_error(request_id: &RequestId, error: OutboundError) -> ApiError { +pub(super) fn protocol_error(request_id: &RequestId, error: ProtocolError) -> ApiError { + if error.category == ProtocolErrorCategory::Internal { + return ApiError::internal_logged(request_id, error); + } + let status = match error.category { + ProtocolErrorCategory::InvalidInput => match error.code { + "private_network_denied" | "forbidden_network_target" => StatusCode::FORBIDDEN, + "outbound_headers_too_large" + | "outbound_request_too_large" + | "upstream_headers_too_large" + | "upstream_response_too_large" + | "openapi_document_too_large" => StatusCode::PAYLOAD_TOO_LARGE, + _ => StatusCode::BAD_REQUEST, + }, + ProtocolErrorCategory::NotFound => StatusCode::NOT_FOUND, + ProtocolErrorCategory::Conflict | ProtocolErrorCategory::CorruptData => { + StatusCode::CONFLICT + } + ProtocolErrorCategory::Unsupported => StatusCode::BAD_REQUEST, + ProtocolErrorCategory::Upstream if error.code == "upstream_timeout" => { + StatusCode::GATEWAY_TIMEOUT + } + ProtocolErrorCategory::Upstream => StatusCode::BAD_GATEWAY, + ProtocolErrorCategory::Internal => unreachable!("internal protocol errors return above"), + }; + ApiError::new(request_id, status, error.code, error.message) +} + +pub(super) fn outbound_error(request_id: &RequestId, error: OutboundError) -> ApiError { let status = match error { OutboundError::PrivateAddress | OutboundError::ForbiddenAddress => StatusCode::FORBIDDEN, OutboundError::ResponseBodyTooLarge diff --git a/src/approval/mod.rs b/src/approval/mod.rs new file mode 100644 index 000000000..717b57938 --- /dev/null +++ b/src/approval/mod.rs @@ -0,0 +1,2062 @@ +use std::{ + fmt, + str::FromStr, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + actor::{ActorKind, ToolActor}, + catalog::{InvocationRevisionToken, ModeProvenance, NewRequestLog, RequestSurface}, + crypto::{CryptoError, Keyring}, +}; + +#[path = "../invocation/mod.rs"] +pub mod invocation; + +const APPROVAL_TTL_SECONDS: i64 = 10 * 60; +#[cfg(test)] +const APPROVAL_CORRELATION_TTL_SECONDS: i64 = 24 * 60 * 60; +const MAX_APPROVAL_ARGUMENT_BYTES: usize = 8 * 1024 * 1024; +const MAX_APPROVAL_SCHEMA_BYTES: usize = 2 * 1024 * 1024; +const MAX_APPROVAL_SNAPSHOT_BYTES: usize = 8 * 1024 * 1024; +const MAX_APPROVAL_RESULT_BYTES: usize = 8 * 1024 * 1024; +const MAX_APPROVAL_PAGE_LIMIT: u32 = 100; +const MAX_ACTIVE_APPROVALS: i64 = 1_024; +const MAX_ACTIVE_APPROVALS_PER_OWNER: i64 = 128; +const MAX_ACTIVE_APPROVAL_CIPHERTEXT_BYTES: i64 = 64 * 1024 * 1024; +const MAX_TERMINAL_APPROVAL_CIPHERTEXT_BYTES: i64 = 64 * 1024 * 1024; +const MAX_APPROVAL_DELIVERY_PINS: i64 = 128; +const MAX_APPROVAL_DELIVERY_PIN_REFS: i64 = 128; +const MAX_PINNED_SNAPSHOT_CIPHERTEXT_BYTES: i64 = 64 * 1024 * 1024; +const MAX_APPROVAL_CORRELATIONS: i64 = 100_000; + +const TERMINAL_RETENTION: i64 = 10_000; + +trait Clock: Send + Sync + 'static { + fn now(&self) -> i64; +} + +#[derive(Clone, Copy, Debug, Default)] +struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time must be after the Unix epoch") + .as_secs() as i64 + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalStatus { + Pending, + Approved, + Denied, + Expired, + Canceled, + Executing, + Succeeded, + Failed, + Stale, + Interrupted, +} + +impl ApprovalStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Denied => "denied", + Self::Expired => "expired", + Self::Canceled => "canceled", + Self::Executing => "executing", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::Stale => "stale", + Self::Interrupted => "interrupted", + } + } + + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Denied + | Self::Expired + | Self::Canceled + | Self::Succeeded + | Self::Failed + | Self::Stale + | Self::Interrupted + ) + } +} + +impl fmt::Display for ApprovalStatus { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for ApprovalStatus { + type Err = ApprovalError; + + fn from_str(value: &str) -> Result { + match value { + "pending" => Ok(Self::Pending), + "approved" => Ok(Self::Approved), + "denied" => Ok(Self::Denied), + "expired" => Ok(Self::Expired), + "canceled" => Ok(Self::Canceled), + "executing" => Ok(Self::Executing), + "succeeded" => Ok(Self::Succeeded), + "failed" => Ok(Self::Failed), + "stale" => Ok(Self::Stale), + "interrupted" => Ok(Self::Interrupted), + _ => Err(ApprovalError::CorruptData("unknown approval status")), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision { + Approve, + Deny, +} + +impl ApprovalDecision { + const fn as_str(self) -> &'static str { + match self { + Self::Approve => "approve", + Self::Deny => "deny", + } + } + + const fn status(self) -> ApprovalStatus { + match self { + Self::Approve => ApprovalStatus::Approved, + Self::Deny => ApprovalStatus::Denied, + } + } +} + +impl FromStr for ApprovalDecision { + type Err = ApprovalError; + + fn from_str(value: &str) -> Result { + match value { + "approve" => Ok(Self::Approve), + "deny" => Ok(Self::Deny), + _ => Err(ApprovalError::CorruptData("unknown approval decision")), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExecutionOutcome { + Succeeded, + Failed, + Interrupted, +} + +impl ExecutionOutcome { + const fn status(self) -> ApprovalStatus { + match self { + Self::Succeeded => ApprovalStatus::Succeeded, + Self::Failed => ApprovalStatus::Failed, + Self::Interrupted => ApprovalStatus::Interrupted, + } + } +} + +#[derive(Clone, Debug)] +struct NewApproval { + pub execution_id: String, + pub call_id: String, + pub worker_generation: u64, + pub actor: ToolActor, + pub surface: RequestSurface, + pub callable_path_snapshot: String, + pub source_display_name_snapshot: Option, + pub tool_display_name_snapshot: Option, + pub mode_provenance: ModeProvenance, + pub revisions: InvocationRevisionToken, + pub arguments: Value, + pub input_schema: Value, + pub output_schema: Option, + pub invocation_snapshot: Value, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalRecord { + pub sequence: i64, + pub id: String, + pub execution_id: String, + pub call_id: String, + pub worker_generation: u64, + pub actor_kind: ActorKind, + pub actor_id: String, + pub actor_api_token_id: Option, + pub actor_name_snapshot: Option, + pub surface: RequestSurface, + pub callable_path_snapshot: String, + pub source_display_name: Option, + pub tool_display_name: Option, + pub mode_provenance: ModeProvenance, + pub revisions: InvocationRevisionTokenSnapshot, + pub status: ApprovalStatus, + pub revision: i64, + pub decision_id: Option, + pub decision: Option, + pub decided_by_admin_id: Option, + pub failure_code: Option, + pub created_at: i64, + pub updated_at: i64, + pub expires_at: i64, + pub decided_at: Option, + pub execution_started_at: Option, + pub completed_at: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvocationRevisionTokenSnapshot { + pub source_id: String, + pub tool_id: String, + pub source_revision: i64, + pub catalog_revision: i64, + pub tool_revision: i64, + pub binding_revision: i64, + pub credential_revision: Option, +} + +impl From<&InvocationRevisionToken> for InvocationRevisionTokenSnapshot { + fn from(value: &InvocationRevisionToken) -> Self { + Self { + source_id: value.source_id.clone(), + tool_id: value.tool_id.clone(), + source_revision: value.source_revision, + catalog_revision: value.catalog_revision, + tool_revision: value.tool_revision, + binding_revision: value.binding_revision, + credential_revision: value.credential_revision, + } + } +} + +impl From for InvocationRevisionToken { + fn from(value: InvocationRevisionTokenSnapshot) -> Self { + Self { + source_id: value.source_id, + tool_id: value.tool_id, + source_revision: value.source_revision, + catalog_revision: value.catalog_revision, + tool_revision: value.tool_revision, + binding_revision: value.binding_revision, + credential_revision: value.credential_revision, + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalAdminDetail { + #[serde(flatten)] + pub record: ApprovalRecord, + pub redacted_arguments: Value, + pub input_schema: Value, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalOwnerDetail { + #[serde(flatten)] + pub record: ApprovalRecord, + pub result: Option, +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +struct ApprovalExecutionSnapshot { + pub record: ApprovalRecord, + pub arguments: Value, + pub input_schema: Value, + pub output_schema: Option, + pub invocation_snapshot: Value, + pub result: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ApprovalListQuery { + pub before_sequence: Option, + pub limit: u32, + pub status: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalPage { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Clone, Debug)] +struct DecisionResult { + pub record: ApprovalRecord, + pub idempotent: bool, +} + +#[derive(Clone, Debug)] +struct ApprovalCreateResult { + pub record: ApprovalRecord, + pub delivery_pin: bool, + #[cfg(test)] + pub redacted_arguments: Value, + #[cfg(test)] + pub input_schema: Value, + #[cfg(test)] + pub reused: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct ApprovalDeliveryIdentity { + pub approval_id: String, + pub actor: ToolActor, + pub execution_id: String, + pub call_id: String, +} + +impl ApprovalCreateResult { + fn from_detail(detail: ApprovalAdminDetail, reused: bool, delivery_pin: bool) -> Self { + #[cfg(not(test))] + let _ = reused; + Self { + record: detail.record, + delivery_pin, + #[cfg(test)] + redacted_arguments: detail.redacted_arguments, + #[cfg(test)] + input_schema: detail.input_schema, + #[cfg(test)] + reused, + } + } +} + +#[derive(Clone, Debug, Default)] +struct StartupRecovery { + #[cfg(test)] + pub interrupted_count: u64, + pub approved_ids: Vec, +} + +#[derive(Clone)] +struct ApprovalStore { + pool: SqlitePool, + keyring: Keyring, + clock: Arc, + transition_slot: Arc, +} + +struct Transition<'a> { + from: ApprovalStatus, + to: ApprovalStatus, + failure_code: Option<&'a str>, + result_ciphertext: Option>, +} + +impl ApprovalStore { + #[cfg(test)] + fn new(pool: SqlitePool, keyring: Keyring, clock: Arc) -> Self { + Self { + pool, + keyring, + clock, + transition_slot: Arc::new(tokio::sync::Semaphore::new(1)), + } + } + + fn system(pool: SqlitePool, keyring: Keyring) -> Self { + Self { + pool, + keyring, + clock: Arc::new(SystemClock), + transition_slot: Arc::new(tokio::sync::Semaphore::new(1)), + } + } + + async fn create(&self, new: NewApproval) -> Result { + validate_new(&new)?; + let observed_now = self.advance_clock().await?; + let id = Uuid::new_v4().to_string(); + let arguments = encode_json("arguments", &new.arguments, MAX_APPROVAL_ARGUMENT_BYTES)?; + let redacted_arguments = encode_json( + "redacted arguments", + &redact_arguments(&new.arguments, &new.input_schema), + MAX_APPROVAL_ARGUMENT_BYTES, + )?; + let input_schema = + encode_json("input schema", &new.input_schema, MAX_APPROVAL_SCHEMA_BYTES)?; + let output_schema = new + .output_schema + .as_ref() + .map(|value| encode_json("output schema", value, MAX_APPROVAL_SCHEMA_BYTES)) + .transpose()?; + let invocation_snapshot = encode_json( + "invocation snapshot", + &new.invocation_snapshot, + MAX_APPROVAL_SNAPSHOT_BYTES, + )?; + let canonical_arguments = canonical_json_bytes(&new.arguments)?; + let arguments_digest = self + .keyring + .digest("approval-arguments", &canonical_arguments) + .to_vec(); + let arguments_ciphertext = self + .keyring + .encrypt("approval-arguments", &id, &arguments)?; + let redacted_arguments_ciphertext = + self.keyring + .encrypt("approval-redacted-arguments", &id, &redacted_arguments)?; + let input_schema_ciphertext = + self.keyring + .encrypt("approval-input-schema", &id, &input_schema)?; + let output_schema_ciphertext = output_schema + .as_deref() + .map(|value| self.keyring.encrypt("approval-output-schema", &id, value)) + .transpose()?; + let invocation_snapshot_ciphertext = + self.keyring + .encrypt("approval-invocation-snapshot", &id, &invocation_snapshot)?; + let worker_generation = + i64::try_from(new.worker_generation).map_err(|_| ApprovalError::Validation { + code: "invalid_worker_generation", + message: "worker generation exceeds the supported range".to_owned(), + })?; + let actor_kind = new.actor.kind().as_str(); + let actor_id = new.actor.id(); + let actor_api_token_id = new.actor.api_token_id(); + let actor_name_snapshot = new.actor.name_snapshot(); + let new_ciphertext_bytes = [ + arguments_ciphertext.len(), + redacted_arguments_ciphertext.len(), + input_schema_ciphertext.len(), + output_schema_ciphertext.as_ref().map_or(0, Vec::len), + invocation_snapshot_ciphertext.len(), + ] + .into_iter() + .try_fold(0_i64, |total, length| { + i64::try_from(length) + .ok() + .and_then(|length| total.checked_add(length)) + }) + .ok_or(ApprovalError::Capacity { + scope: "active_bytes", + })?; + + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + let expires_at = + now.checked_add(APPROVAL_TTL_SECONDS) + .ok_or(ApprovalError::Validation { + code: "invalid_approval_ttl", + message: "approval expiry exceeds the supported timestamp range".to_owned(), + })?; + expire_pending_in(&mut transaction, now).await?; + enforce_retention(&mut transaction).await?; + reclaim_expired_correlations_in(&mut transaction, now).await?; + if let Some(correlation) = fetch_correlation_in( + &mut transaction, + actor_kind, + &actor_id, + &new.execution_id, + &new.call_id, + ) + .await? + { + ensure_same_correlation( + &correlation, + actor_kind, + &actor_id, + new.surface, + worker_generation, + &new.callable_path_snapshot, + &arguments_digest, + )?; + let existing = fetch_row_in(&mut transaction, &correlation.approval_id) + .await? + .ok_or(ApprovalError::CorrelationRetired)?; + let delivery_pin = if new.worker_generation > 0 { + add_delivery_pin_in( + &mut transaction, + &correlation.approval_id, + existing_ciphertext_bytes(&existing)?, + now, + ) + .await?; + true + } else { + false + }; + let detail = self.decode_admin_detail(existing)?; + transaction.commit().await?; + return Ok(ApprovalCreateResult::from_detail( + detail, + true, + delivery_pin, + )); + } + let correlation_count = sqlx::query_scalar::<_, i64>( + "SELECT correlation_count FROM approval_correlation_state WHERE id = 1", + ) + .fetch_one(&mut *transaction) + .await?; + if correlation_count >= MAX_APPROVAL_CORRELATIONS { + return Err(ApprovalError::Capacity { + scope: "correlations", + }); + } + let global_active = active_count_in(&mut transaction, None).await?; + if global_active >= MAX_ACTIVE_APPROVALS { + return Err(ApprovalError::Capacity { scope: "global" }); + } + let owner_active = active_count_in(&mut transaction, Some((actor_kind, &actor_id))).await?; + if owner_active >= MAX_ACTIVE_APPROVALS_PER_OWNER { + return Err(ApprovalError::Capacity { scope: "owner" }); + } + let active_ciphertext_bytes = active_ciphertext_bytes_in(&mut transaction).await?; + if active_ciphertext_bytes + .checked_add(new_ciphertext_bytes) + .is_none_or(|total| total > MAX_ACTIVE_APPROVAL_CIPHERTEXT_BYTES) + { + return Err(ApprovalError::Capacity { + scope: "active_bytes", + }); + } + + let inserted = sqlx::query( + "INSERT INTO approvals (id, execution_id, call_id, worker_generation, \ + actor_kind, actor_id, actor_api_token_id, actor_name_snapshot, surface, source_id, tool_id, \ + callable_path_snapshot, source_display_name_snapshot, tool_display_name_snapshot, \ + mode_provenance, \ + source_revision, catalog_revision, tool_revision, binding_revision, credential_revision, \ + arguments_digest, arguments_ciphertext, redacted_arguments_ciphertext, input_schema_ciphertext, \ + output_schema_ciphertext, invocation_snapshot_ciphertext, status, revision, \ + created_at, updated_at, expires_at) \ + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ + 'pending', 0, ?, ?, ? WHERE (? <> 'api_token' OR EXISTS (SELECT 1 FROM api_tokens \ + WHERE id = ? AND revoked_at IS NULL)) \ + AND NOT EXISTS (SELECT 1 FROM approval_correlations \ + WHERE actor_kind = ? AND actor_id = ? AND execution_id = ? AND call_id = ?) \ + ON CONFLICT(actor_kind, actor_id, execution_id, call_id) DO NOTHING", + ) + .bind(&id) + .bind(&new.execution_id) + .bind(&new.call_id) + .bind(worker_generation) + .bind(actor_kind) + .bind(&actor_id) + .bind(actor_api_token_id) + .bind(actor_name_snapshot) + .bind(new.surface.as_str()) + .bind(&new.revisions.source_id) + .bind(&new.revisions.tool_id) + .bind(&new.callable_path_snapshot) + .bind(new.source_display_name_snapshot) + .bind(new.tool_display_name_snapshot) + .bind(mode_provenance_str(new.mode_provenance)) + .bind(new.revisions.source_revision) + .bind(new.revisions.catalog_revision) + .bind(new.revisions.tool_revision) + .bind(new.revisions.binding_revision) + .bind(new.revisions.credential_revision) + .bind(&arguments_digest) + .bind(arguments_ciphertext) + .bind(redacted_arguments_ciphertext) + .bind(input_schema_ciphertext) + .bind(output_schema_ciphertext) + .bind(invocation_snapshot_ciphertext) + .bind(now) + .bind(now) + .bind(expires_at) + .bind(actor_kind) + .bind(actor_api_token_id) + .bind(actor_kind) + .bind(&actor_id) + .bind(&new.execution_id) + .bind(&new.call_id) + .execute(&mut *transaction) + .await?; + if inserted.rows_affected() != 1 { + if let Some(correlation) = fetch_correlation_in( + &mut transaction, + actor_kind, + &actor_id, + &new.execution_id, + &new.call_id, + ) + .await? + { + ensure_same_correlation( + &correlation, + actor_kind, + &actor_id, + new.surface, + worker_generation, + &new.callable_path_snapshot, + &arguments_digest, + )?; + let existing = fetch_row_in(&mut transaction, &correlation.approval_id) + .await? + .ok_or(ApprovalError::CorrelationRetired)?; + let delivery_pin = if new.worker_generation > 0 { + add_delivery_pin_in( + &mut transaction, + &correlation.approval_id, + existing_ciphertext_bytes(&existing)?, + now, + ) + .await?; + true + } else { + false + }; + let detail = self.decode_admin_detail(existing)?; + transaction.commit().await?; + return Ok(ApprovalCreateResult::from_detail( + detail, + true, + delivery_pin, + )); + } + return Err(ApprovalError::OwnerTokenInactive); + } + let row = fetch_row_in(&mut transaction, &id) + .await? + .ok_or(ApprovalError::NotFound)?; + let delivery_pin = if new.worker_generation > 0 { + add_delivery_pin_in(&mut transaction, &id, new_ciphertext_bytes, now).await?; + true + } else { + false + }; + let detail = self.decode_admin_detail(row)?; + transaction.commit().await?; + Ok(ApprovalCreateResult::from_detail( + detail, + false, + delivery_pin, + )) + } + + async fn get_admin(&self, id: &str) -> Result, ApprovalError> { + let row = fetch_row(&self.pool, id).await?; + row.map(|row| self.decode_admin_detail(row)).transpose() + } + + async fn release_delivery_pin( + &self, + identity: &ApprovalDeliveryIdentity, + ) -> Result { + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let pin = sqlx::query_scalar::<_, i64>( + "SELECT pins.ref_count FROM approval_delivery_pins AS pins \ + JOIN approvals ON approvals.id = pins.approval_id \ + WHERE pins.approval_id = ? AND approvals.actor_kind = ? \ + AND approvals.actor_id = ? AND approvals.execution_id = ? AND approvals.call_id = ?", + ) + .bind(&identity.approval_id) + .bind(identity.actor.kind().as_str()) + .bind(identity.actor.id()) + .bind(&identity.execution_id) + .bind(&identity.call_id) + .fetch_optional(&mut *transaction) + .await?; + let released = match pin { + Some(1) => { + sqlx::query("DELETE FROM approval_delivery_pins WHERE approval_id = ?") + .bind(&identity.approval_id) + .execute(&mut *transaction) + .await?; + true + } + Some(_) => { + sqlx::query( + "UPDATE approval_delivery_pins SET ref_count = ref_count - 1 \ + WHERE approval_id = ?", + ) + .bind(&identity.approval_id) + .execute(&mut *transaction) + .await?; + true + } + None => false, + }; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(released) + } + + async fn delivery_pin_exists(&self, id: &str) -> Result { + Ok(sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM approval_delivery_pins WHERE approval_id = ?)", + ) + .bind(id) + .fetch_one(&self.pool) + .await? + != 0) + } + + async fn clear_delivery_pins(&self) -> Result { + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let affected = sqlx::query("DELETE FROM approval_delivery_pins") + .execute(&mut *transaction) + .await? + .rows_affected(); + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(affected) + } + + async fn get_for_token( + &self, + id: &str, + actor_api_token_id: &str, + ) -> Result, ApprovalError> { + let row = sqlx::query_as::<_, ApprovalRow>(&format!( + "{} WHERE id = ? AND actor_kind = 'api_token' AND actor_api_token_id = ?", + APPROVAL_SELECT + )) + .bind(id) + .bind(actor_api_token_id) + .fetch_optional(&self.pool) + .await?; + row.map(|row| self.decode_owner_detail(row)).transpose() + } + + async fn get_for_actor( + &self, + id: &str, + actor: &ToolActor, + ) -> Result, ApprovalError> { + let row = sqlx::query_as::<_, ApprovalRow>(&format!( + "{} WHERE id = ? AND actor_kind = ? AND actor_id = ?", + APPROVAL_SELECT + )) + .bind(id) + .bind(actor.kind().as_str()) + .bind(actor.id()) + .fetch_optional(&self.pool) + .await?; + row.map(|row| self.decode_owner_detail(row)).transpose() + } + + async fn list_admin(&self, query: ApprovalListQuery) -> Result { + let limit = if query.limit == 0 { + 50 + } else { + query.limit.min(MAX_APPROVAL_PAGE_LIMIT) + }; + let before = query.before_sequence.unwrap_or(i64::MAX); + if before <= 0 { + return Err(ApprovalError::Validation { + code: "invalid_approval_cursor", + message: "approval cursor must be a positive integer".to_owned(), + }); + } + let fetch_limit = i64::from(limit) + 1; + let rows = if let Some(status) = query.status { + sqlx::query_as::<_, ApprovalRow>(&format!( + "{} WHERE sequence < ? AND status = ? ORDER BY sequence DESC LIMIT ?", + APPROVAL_SELECT + )) + .bind(before) + .bind(status.as_str()) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query_as::<_, ApprovalRow>(&format!( + "{} WHERE sequence < ? ORDER BY sequence DESC LIMIT ?", + APPROVAL_SELECT + )) + .bind(before) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await? + }; + let has_more = rows.len() > limit as usize; + let mut items = rows + .into_iter() + .take(limit as usize) + .map(|row| row.record()) + .collect::, _>>()?; + let next_cursor = has_more + .then(|| items.last().map(|item| item.sequence)) + .flatten(); + items.shrink_to_fit(); + Ok(ApprovalPage { items, next_cursor }) + } + + async fn log_outbox(&self, limit: u32) -> Result, ApprovalError> { + let limit = i64::from(limit.clamp(1, 256)); + let rows = sqlx::query_as::<_, ApprovalLogOutboxRow>( + "SELECT outbox.request_id, outbox.approval_id, outbox.actor_api_token_id, \ + outbox.surface, \ + CASE WHEN EXISTS(SELECT 1 FROM sources WHERE id = outbox.source_id) \ + THEN outbox.source_id ELSE NULL END AS source_id, \ + CASE WHEN EXISTS(SELECT 1 FROM tools WHERE id = outbox.tool_id) \ + THEN outbox.tool_id ELSE NULL END AS tool_id, \ + outbox.path_snapshot, outbox.outcome, outbox.error_code, outbox.created_at \ + FROM approval_log_outbox AS outbox ORDER BY rowid LIMIT ?", + ) + .bind(limit) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(NewRequestLog { + request_id: row.request_id, + actor_api_token_id: row.actor_api_token_id, + surface: row + .surface + .parse() + .map_err(|_| ApprovalError::CorruptData("approval log surface"))?, + source_id: row.source_id, + tool_id: row.tool_id, + path_snapshot: Some(row.path_snapshot), + outcome: row + .outcome + .parse() + .map_err(|_| ApprovalError::CorruptData("approval log outcome"))?, + error_code: row.error_code, + duration_ms: 0, + approval_id: Some(row.approval_id), + created_at: row.created_at, + }) + }) + .collect() + } + + async fn acknowledge_log_outbox(&self, request_id: &str) -> Result<(), ApprovalError> { + validate_identifier("request ID", request_id, 128)?; + sqlx::query("DELETE FROM approval_log_outbox WHERE request_id = ?") + .bind(request_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn decide( + &self, + id: &str, + decision_id: &str, + expected_revision: i64, + decision: ApprovalDecision, + admin_id: i64, + ) -> Result { + validate_identifier("decision ID", decision_id, 128)?; + if expected_revision < 0 || admin_id <= 0 { + return Err(ApprovalError::Validation { + code: "invalid_approval_decision", + message: "approval decision metadata is invalid".to_owned(), + }); + } + self.expire_pending().await?; + let observed_now = self.advance_clock().await?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + + let current = fetch_row_in(&mut transaction, id) + .await? + .ok_or(ApprovalError::NotFound)?; + if let Some(existing) = current.decision.as_deref() { + let existing: ApprovalDecision = existing.parse()?; + if existing == decision { + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + return Ok(DecisionResult { + record: current.record()?, + idempotent: true, + }); + } + return Err(ApprovalError::DecisionConflict); + } + let current_status: ApprovalStatus = current.status.parse()?; + if current_status == ApprovalStatus::Expired { + return Err(ApprovalError::Expired); + } + if current_status != ApprovalStatus::Pending { + return Err(ApprovalError::DecisionConflict); + } + if current.revision != expected_revision { + return Err(ApprovalError::RevisionConflict { + expected: expected_revision, + actual: current.revision, + }); + } + let completed_at = (decision == ApprovalDecision::Deny).then_some(now); + let updated = sqlx::query( + "UPDATE approvals SET status = ?, revision = revision + 1, decision_id = ?, \ + decision = ?, decided_by_admin_id = ?, decided_at = ?, updated_at = ?, completed_at = ? \ + WHERE id = ? AND status = 'pending' AND revision = ? AND expires_at > ?", + ) + .bind(decision.status().as_str()) + .bind(decision_id) + .bind(decision.as_str()) + .bind(admin_id) + .bind(now) + .bind(now) + .bind(completed_at) + .bind(id) + .bind(expected_revision) + .bind(now) + .execute(&mut *transaction) + .await?; + if updated.rows_affected() != 1 { + if fetch_row_in(&mut transaction, id) + .await? + .is_some_and(|row| row.status == "pending" && row.expires_at <= now) + { + expire_pending_in(&mut transaction, now).await?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + return Err(ApprovalError::Expired); + } + return Err(classify_cas_failure(&mut transaction, id, expected_revision).await?); + } + let audit_id = Uuid::new_v4().to_string(); + let audit_metadata = serde_json::to_string(&serde_json::json!({ + "approvalId": id, + "from": ApprovalStatus::Pending.as_str(), + "to": decision.status().as_str(), + "revision": expected_revision + 1, + }))?; + sqlx::query( + "INSERT INTO audit_events (id, request_id, actor_admin_id, action, source_id, tool_id, \ + target_path_snapshot, metadata_json, created_at) SELECT ?, ?, ?, ?, \ + CASE WHEN EXISTS (SELECT 1 FROM sources WHERE id = ?) THEN ? ELSE NULL END, \ + CASE WHEN EXISTS (SELECT 1 FROM tools WHERE id = ?) THEN ? ELSE NULL END, \ + callable_path_snapshot, ?, ? FROM approvals WHERE id = ?", + ) + .bind(audit_id) + .bind(decision_id) + .bind(admin_id) + .bind(match decision { + ApprovalDecision::Approve => "approval.approve", + ApprovalDecision::Deny => "approval.deny", + }) + .bind(¤t.source_id) + .bind(¤t.source_id) + .bind(¤t.tool_id) + .bind(¤t.tool_id) + .bind(audit_metadata) + .bind(now) + .bind(id) + .execute(&mut *transaction) + .await?; + enforce_audit_retention(&mut transaction).await?; + let record = fetch_row_in(&mut transaction, id) + .await? + .ok_or(ApprovalError::NotFound)? + .record()?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(DecisionResult { + record, + idempotent: false, + }) + } + + async fn claim_execution( + &self, + id: &str, + worker_generation: u64, + expected_revision: i64, + ) -> Result { + let worker_generation = + i64::try_from(worker_generation).map_err(|_| ApprovalError::Validation { + code: "invalid_worker_generation", + message: "worker generation exceeds the supported range".to_owned(), + })?; + self.expire_pending().await?; + let observed_now = self.advance_clock().await?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + let updated = sqlx::query( + "UPDATE approvals SET status = 'executing', revision = revision + 1, \ + updated_at = ?, execution_started_at = ? WHERE id = ? \ + AND worker_generation = ? AND status = 'approved' AND revision = ? \ + AND (actor_kind <> 'api_token' OR EXISTS ( \ + SELECT 1 FROM api_tokens WHERE api_tokens.id = approvals.actor_api_token_id \ + AND api_tokens.revoked_at IS NULL))", + ) + .bind(now) + .bind(now) + .bind(id) + .bind(worker_generation) + .bind(expected_revision) + .execute(&mut *transaction) + .await?; + if updated.rows_affected() != 1 { + let row = fetch_row_in(&mut transaction, id) + .await? + .ok_or(ApprovalError::NotFound)?; + if row.worker_generation != worker_generation { + return Err(ApprovalError::WorkerGenerationConflict); + } + if row.revision == expected_revision + && row.status == ApprovalStatus::Approved.as_str() + && row.actor_kind == ActorKind::ApiToken.as_str() + && !token_active_in( + &mut transaction, + row.actor_api_token_id + .as_deref() + .ok_or(ApprovalError::CorruptData("missing actor token ID"))?, + ) + .await? + { + return Err(ApprovalError::OwnerTokenInactive); + } + return Err(classify_row_cas_failure(&row, expected_revision)); + } + let row = fetch_row_in(&mut transaction, id) + .await? + .ok_or(ApprovalError::NotFound)?; + let snapshot = self.decode_execution_snapshot(row)?; + transaction.commit().await?; + Ok(snapshot) + } + + async fn mark_stale( + &self, + id: &str, + expected_revision: i64, + failure_code: &str, + ) -> Result { + validate_failure_code(failure_code)?; + self.transition( + id, + expected_revision, + Transition { + from: ApprovalStatus::Approved, + to: ApprovalStatus::Stale, + failure_code: Some(failure_code), + result_ciphertext: None, + }, + ) + .await + } + + async fn finish( + &self, + id: &str, + expected_revision: i64, + outcome: ExecutionOutcome, + result: &Value, + failure_code: Option<&str>, + ) -> Result { + let _transition_permit = self + .transition_slot + .acquire() + .await + .expect("approval transition semaphore is never closed"); + if let Some(code) = failure_code { + validate_failure_code(code)?; + } + if outcome == ExecutionOutcome::Succeeded && failure_code.is_some() { + return Err(ApprovalError::Validation { + code: "invalid_approval_result", + message: "a successful approval execution cannot have a failure code".to_owned(), + }); + } + let encoded = encode_json("result", result, MAX_APPROVAL_RESULT_BYTES)?; + let ciphertext = self.keyring.encrypt("approval-result", id, &encoded)?; + self.transition_inner( + id, + expected_revision, + Transition { + from: ApprovalStatus::Executing, + to: outcome.status(), + failure_code, + result_ciphertext: Some(ciphertext), + }, + ) + .await + } + + async fn expire_pending(&self) -> Result { + let observed_now = self.advance_clock().await?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + let affected = expire_pending_in(&mut transaction, now).await?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(affected) + } + + async fn cancel_token_owner( + &self, + id: &str, + actor_api_token_id: &str, + expected_revision: i64, + ) -> Result { + self.expire_pending().await?; + let observed_now = self.advance_clock().await?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + let updated = sqlx::query( + "UPDATE approvals SET status = 'canceled', revision = revision + 1, \ + updated_at = ?, completed_at = ? \ + WHERE id = ? AND actor_kind = 'api_token' AND actor_api_token_id = ? AND revision = ? \ + AND status IN ('pending', 'approved')", + ) + .bind(now) + .bind(now) + .bind(id) + .bind(actor_api_token_id) + .bind(expected_revision) + .execute(&mut *transaction) + .await?; + if updated.rows_affected() != 1 { + let row = fetch_row_in(&mut transaction, id) + .await? + .ok_or(ApprovalError::NotFound)?; + if row.actor_kind != ActorKind::ApiToken.as_str() + || row.actor_api_token_id.as_deref() != Some(actor_api_token_id) + { + return Err(ApprovalError::NotFound); + } + if row.status == ApprovalStatus::Canceled.as_str() { + let record = row.record()?; + transaction.commit().await?; + return Ok(record); + } + return Err(classify_row_cas_failure(&row, expected_revision)); + } + let record = fetch_row_in(&mut transaction, id) + .await? + .ok_or(ApprovalError::NotFound)? + .record()?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(record) + } + + async fn cancel_execution(&self, execution_id: &str) -> Result { + validate_identifier("execution ID", execution_id, 128)?; + self.cancel_where("execution_id", execution_id).await + } + + #[cfg(test)] + async fn cancel_token(&self, actor_api_token_id: &str) -> Result { + validate_identifier("owner token ID", actor_api_token_id, 128)?; + self.cancel_where("actor_api_token_id", actor_api_token_id) + .await + } + + async fn revoke_owner_token(&self, actor_api_token_id: &str) -> Result { + validate_identifier("owner token ID", actor_api_token_id, 128)?; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let now = effective_now_in(&mut transaction, self.clock.now()).await?; + let revoked = + sqlx::query("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") + .bind(now) + .bind(actor_api_token_id) + .execute(&mut *transaction) + .await?; + if revoked.rows_affected() == 0 { + transaction.commit().await?; + return Ok(false); + } + sqlx::query( + "UPDATE approvals SET status = 'canceled', revision = revision + 1, \ + updated_at = ?, completed_at = ? WHERE actor_kind = 'api_token' \ + AND actor_api_token_id = ? \ + AND status IN ('pending', 'approved')", + ) + .bind(now) + .bind(now) + .bind(actor_api_token_id) + .execute(&mut *transaction) + .await?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(true) + } + + async fn recover_startup(&self) -> Result { + let observed_now = self.advance_clock().await?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + expire_pending_in(&mut transaction, now).await?; + sqlx::query( + "UPDATE approvals SET status = 'canceled', revision = revision + 1, \ + updated_at = ?, completed_at = ? WHERE status IN ('pending', 'approved') \ + AND actor_kind = 'api_token' \ + AND EXISTS (SELECT 1 FROM api_tokens WHERE api_tokens.id = approvals.actor_api_token_id \ + AND api_tokens.revoked_at IS NOT NULL)", + ) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + let _interrupted_count = sqlx::query( + "UPDATE approvals SET status = 'interrupted', revision = revision + 1, \ + failure_code = 'server_restart', updated_at = ?, completed_at = ? \ + WHERE status = 'executing'", + ) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await? + .rows_affected(); + sqlx::query( + "UPDATE approvals SET status = 'canceled', revision = revision + 1, \ + failure_code = 'continuation_lost', updated_at = ?, completed_at = ? \ + WHERE status = 'pending' AND worker_generation > 0", + ) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + sqlx::query( + "UPDATE approvals SET status = 'stale', revision = revision + 1, \ + failure_code = 'continuation_lost', updated_at = ?, completed_at = ? \ + WHERE status = 'approved' AND worker_generation > 0", + ) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + let approved_ids = sqlx::query_scalar::<_, String>( + "SELECT id FROM approvals WHERE status = 'approved' AND worker_generation = 0 \ + ORDER BY sequence ASC", + ) + .fetch_all(&mut *transaction) + .await?; + sqlx::query("DELETE FROM approval_delivery_pins") + .execute(&mut *transaction) + .await?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(StartupRecovery { + #[cfg(test)] + interrupted_count: _interrupted_count, + approved_ids, + }) + } + + async fn transition( + &self, + id: &str, + expected_revision: i64, + transition: Transition<'_>, + ) -> Result { + let _transition_permit = self + .transition_slot + .acquire() + .await + .expect("approval transition semaphore is never closed"); + self.transition_inner(id, expected_revision, transition) + .await + } + + async fn transition_inner( + &self, + id: &str, + expected_revision: i64, + transition: Transition<'_>, + ) -> Result { + if expected_revision < 0 { + return Err(ApprovalError::Validation { + code: "invalid_approval_revision", + message: "approval revision cannot be negative".to_owned(), + }); + } + self.expire_pending().await?; + let observed_now = self.advance_clock().await?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + let completed_at = transition.to.is_terminal().then_some(now); + let updated = sqlx::query( + "UPDATE approvals SET status = ?, revision = revision + 1, failure_code = ?, \ + result_ciphertext = COALESCE(?, result_ciphertext), updated_at = ?, \ + completed_at = ? WHERE id = ? AND status = ? AND revision = ?", + ) + .bind(transition.to.as_str()) + .bind(transition.failure_code) + .bind(transition.result_ciphertext) + .bind(now) + .bind(completed_at) + .bind(id) + .bind(transition.from.as_str()) + .bind(expected_revision) + .execute(&mut *transaction) + .await?; + if updated.rows_affected() != 1 { + return Err(classify_cas_failure(&mut transaction, id, expected_revision).await?); + } + let record = fetch_row_in(&mut transaction, id) + .await? + .ok_or(ApprovalError::NotFound)? + .record()?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(record) + } + + async fn cancel_where(&self, column: &'static str, value: &str) -> Result { + let observed_now = self.advance_clock().await?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + expire_pending_in(&mut transaction, now).await?; + let result = sqlx::query(&format!( + "UPDATE approvals SET status = 'canceled', revision = revision + 1, \ + updated_at = ?, completed_at = ? WHERE {column} = ? \ + AND status IN ('pending', 'approved')" + )) + .bind(now) + .bind(now) + .bind(value) + .execute(&mut *transaction) + .await?; + enforce_retention(&mut transaction).await?; + transaction.commit().await?; + Ok(result.rows_affected()) + } + + async fn advance_clock(&self) -> Result { + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, self.clock.now()).await?; + transaction.commit().await?; + Ok(now) + } + + fn decode_admin_detail(&self, row: ApprovalRow) -> Result { + let redacted_arguments = decrypt_json( + &self.keyring, + "approval-redacted-arguments", + &row.id, + &row.redacted_arguments_ciphertext, + )?; + let input_schema = decrypt_json( + &self.keyring, + "approval-input-schema", + &row.id, + &row.input_schema_ciphertext, + )?; + let record = row.record()?; + Ok(ApprovalAdminDetail { + record, + redacted_arguments, + input_schema, + }) + } + + fn decode_owner_detail(&self, row: ApprovalRow) -> Result { + let result = row + .result_ciphertext + .as_deref() + .map(|sealed| decrypt_json(&self.keyring, "approval-result", &row.id, sealed)) + .transpose()?; + let record = row.record()?; + Ok(ApprovalOwnerDetail { record, result }) + } + + fn decode_execution_snapshot( + &self, + row: ApprovalRow, + ) -> Result { + let arguments = decrypt_json( + &self.keyring, + "approval-arguments", + &row.id, + &row.arguments_ciphertext, + )?; + let input_schema = decrypt_json( + &self.keyring, + "approval-input-schema", + &row.id, + &row.input_schema_ciphertext, + )?; + let output_schema = row + .output_schema_ciphertext + .as_deref() + .map(|sealed| decrypt_json(&self.keyring, "approval-output-schema", &row.id, sealed)) + .transpose()?; + let invocation_snapshot = decrypt_json( + &self.keyring, + "approval-invocation-snapshot", + &row.id, + &row.invocation_snapshot_ciphertext, + )?; + let result = row + .result_ciphertext + .as_deref() + .map(|sealed| decrypt_json(&self.keyring, "approval-result", &row.id, sealed)) + .transpose()?; + let record = row.record()?; + Ok(ApprovalExecutionSnapshot { + record, + arguments, + input_schema, + output_schema, + invocation_snapshot, + result, + }) + } +} + +#[derive(FromRow)] +struct ApprovalRow { + sequence: i64, + id: String, + execution_id: String, + call_id: String, + worker_generation: i64, + actor_kind: String, + actor_id: String, + actor_api_token_id: Option, + actor_name_snapshot: Option, + surface: String, + source_id: String, + tool_id: String, + callable_path_snapshot: String, + source_display_name_snapshot: Option, + tool_display_name_snapshot: Option, + mode_provenance: String, + source_revision: i64, + catalog_revision: i64, + tool_revision: i64, + binding_revision: i64, + credential_revision: Option, + arguments_ciphertext: Vec, + redacted_arguments_ciphertext: Vec, + input_schema_ciphertext: Vec, + output_schema_ciphertext: Option>, + invocation_snapshot_ciphertext: Vec, + result_ciphertext: Option>, + status: String, + revision: i64, + decision_id: Option, + decision: Option, + decided_by_admin_id: Option, + failure_code: Option, + created_at: i64, + updated_at: i64, + expires_at: i64, + decided_at: Option, + execution_started_at: Option, + completed_at: Option, +} + +#[derive(FromRow)] +struct ApprovalLogOutboxRow { + request_id: String, + approval_id: String, + actor_api_token_id: Option, + surface: String, + source_id: Option, + tool_id: Option, + path_snapshot: String, + outcome: String, + error_code: Option, + created_at: i64, +} + +#[derive(FromRow)] +struct ApprovalCorrelationRow { + approval_id: String, + actor_kind: String, + actor_id: String, + surface: String, + worker_generation: i64, + callable_path: String, + arguments_digest: Vec, +} + +impl ApprovalRow { + fn record(&self) -> Result { + let actor = ToolActor::from_stored( + &self.actor_kind, + self.actor_id.clone(), + self.actor_api_token_id.clone(), + self.actor_name_snapshot.clone(), + ) + .ok_or(ApprovalError::CorruptData("invalid approval actor"))?; + Ok(ApprovalRecord { + sequence: self.sequence, + id: self.id.clone(), + execution_id: self.execution_id.clone(), + call_id: self.call_id.clone(), + worker_generation: u64::try_from(self.worker_generation) + .map_err(|_| ApprovalError::CorruptData("negative worker generation"))?, + actor_kind: actor.kind(), + actor_id: actor.id(), + actor_api_token_id: actor.api_token_id().map(str::to_owned), + actor_name_snapshot: actor.name_snapshot().map(str::to_owned), + surface: self + .surface + .parse() + .map_err(|_| ApprovalError::CorruptData("unknown approval surface"))?, + callable_path_snapshot: self.callable_path_snapshot.clone(), + source_display_name: self.source_display_name_snapshot.clone(), + tool_display_name: self.tool_display_name_snapshot.clone(), + mode_provenance: parse_mode_provenance(&self.mode_provenance)?, + revisions: InvocationRevisionTokenSnapshot { + source_id: self.source_id.clone(), + tool_id: self.tool_id.clone(), + source_revision: self.source_revision, + catalog_revision: self.catalog_revision, + tool_revision: self.tool_revision, + binding_revision: self.binding_revision, + credential_revision: self.credential_revision, + }, + status: self.status.parse()?, + revision: self.revision, + decision_id: self.decision_id.clone(), + decision: self.decision.as_deref().map(str::parse).transpose()?, + decided_by_admin_id: self.decided_by_admin_id, + failure_code: self.failure_code.clone(), + created_at: self.created_at, + updated_at: self.updated_at, + expires_at: self.expires_at, + decided_at: self.decided_at, + execution_started_at: self.execution_started_at, + completed_at: self.completed_at, + }) + } +} + +const APPROVAL_SELECT: &str = "SELECT sequence, id, execution_id, call_id, worker_generation, \ + actor_kind, actor_id, actor_api_token_id, actor_name_snapshot, surface, source_id, tool_id, callable_path_snapshot, \ + source_display_name_snapshot, tool_display_name_snapshot, mode_provenance, source_revision, catalog_revision, \ + tool_revision, binding_revision, credential_revision, arguments_ciphertext, redacted_arguments_ciphertext, \ + input_schema_ciphertext, output_schema_ciphertext, invocation_snapshot_ciphertext, \ + result_ciphertext, status, revision, decision_id, decision, decided_by_admin_id, failure_code, \ + created_at, updated_at, expires_at, decided_at, execution_started_at, completed_at FROM approvals"; + +async fn fetch_row(pool: &SqlitePool, id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApprovalRow>(&format!("{} WHERE id = ?", APPROVAL_SELECT)) + .bind(id) + .fetch_optional(pool) + .await +} + +async fn fetch_row_in( + transaction: &mut Transaction<'_, Sqlite>, + id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApprovalRow>(&format!("{} WHERE id = ?", APPROVAL_SELECT)) + .bind(id) + .fetch_optional(&mut **transaction) + .await +} + +async fn fetch_correlation_in( + transaction: &mut Transaction<'_, Sqlite>, + actor_kind: &str, + actor_id: &str, + execution_id: &str, + call_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApprovalCorrelationRow>( + "SELECT approval_id, actor_kind, actor_id, surface, worker_generation, \ + callable_path, arguments_digest FROM approval_correlations \ + WHERE actor_kind = ? AND actor_id = ? AND execution_id = ? AND call_id = ?", + ) + .bind(actor_kind) + .bind(actor_id) + .bind(execution_id) + .bind(call_id) + .fetch_optional(&mut **transaction) + .await +} + +fn ensure_same_correlation( + existing: &ApprovalCorrelationRow, + actor_kind: &str, + actor_id: &str, + surface: RequestSurface, + worker_generation: i64, + callable_path: &str, + arguments_digest: &[u8], +) -> Result<(), ApprovalError> { + if existing.actor_kind == actor_kind + && existing.actor_id == actor_id + && existing.surface == surface.as_str() + && existing.worker_generation == worker_generation + && existing.callable_path == callable_path + && existing.arguments_digest == arguments_digest + { + Ok(()) + } else { + Err(ApprovalError::CorrelationConflict) + } +} + +async fn effective_now_in( + transaction: &mut Transaction<'_, Sqlite>, + observed_now: i64, +) -> Result { + sqlx::query("UPDATE approval_clock SET effective_now = MAX(effective_now, ?) WHERE id = 1") + .bind(observed_now.max(0)) + .execute(&mut **transaction) + .await?; + sqlx::query_scalar("SELECT effective_now FROM approval_clock WHERE id = 1") + .fetch_one(&mut **transaction) + .await +} + +async fn active_count_in( + transaction: &mut Transaction<'_, Sqlite>, + actor: Option<(&str, &str)>, +) -> Result { + if let Some((actor_kind, actor_id)) = actor { + sqlx::query_scalar( + "SELECT COUNT(*) FROM approvals WHERE status IN ('pending', 'approved', 'executing') \ + AND actor_kind = ? AND actor_id = ?", + ) + .bind(actor_kind) + .bind(actor_id) + .fetch_one(&mut **transaction) + .await + } else { + sqlx::query_scalar( + "SELECT COUNT(*) FROM approvals WHERE status IN ('pending', 'approved', 'executing')", + ) + .fetch_one(&mut **transaction) + .await + } +} + +async fn active_ciphertext_bytes_in( + transaction: &mut Transaction<'_, Sqlite>, +) -> Result { + sqlx::query_scalar( + "SELECT COALESCE(SUM( \ + length(arguments_ciphertext) + length(redacted_arguments_ciphertext) \ + + length(input_schema_ciphertext) + COALESCE(length(output_schema_ciphertext), 0) \ + + length(invocation_snapshot_ciphertext) + COALESCE(length(result_ciphertext), 0) \ + ), 0) FROM approvals WHERE status IN ('pending', 'approved', 'executing')", + ) + .fetch_one(&mut **transaction) + .await +} + +fn existing_ciphertext_bytes(row: &ApprovalRow) -> Result { + [ + row.arguments_ciphertext.len(), + row.redacted_arguments_ciphertext.len(), + row.input_schema_ciphertext.len(), + row.output_schema_ciphertext.as_ref().map_or(0, Vec::len), + row.invocation_snapshot_ciphertext.len(), + ] + .into_iter() + .try_fold(0_i64, |total, length| { + i64::try_from(length) + .ok() + .and_then(|length| total.checked_add(length)) + }) + .ok_or(ApprovalError::Capacity { + scope: "delivery_pin_bytes", + }) +} + +async fn add_delivery_pin_in( + transaction: &mut Transaction<'_, Sqlite>, + approval_id: &str, + snapshot_ciphertext_bytes: i64, + now: i64, +) -> Result<(), ApprovalError> { + let (pin_count, ref_count, pinned_bytes) = sqlx::query_as::<_, (i64, i64, i64)>( + "SELECT COUNT(*), COALESCE(SUM(ref_count), 0), \ + COALESCE(SUM(snapshot_ciphertext_bytes), 0) FROM approval_delivery_pins", + ) + .fetch_one(&mut **transaction) + .await?; + let existing = sqlx::query_as::<_, (i64, i64)>( + "SELECT ref_count, snapshot_ciphertext_bytes FROM approval_delivery_pins \ + WHERE approval_id = ?", + ) + .bind(approval_id) + .fetch_optional(&mut **transaction) + .await?; + if ref_count >= MAX_APPROVAL_DELIVERY_PIN_REFS { + return Err(ApprovalError::Capacity { + scope: "delivery_pins", + }); + } + if let Some((approval_refs, stored_bytes)) = existing { + if approval_refs >= MAX_APPROVAL_DELIVERY_PIN_REFS + || stored_bytes != snapshot_ciphertext_bytes + { + return Err(ApprovalError::Capacity { + scope: "delivery_pins", + }); + } + sqlx::query( + "UPDATE approval_delivery_pins SET ref_count = ref_count + 1 WHERE approval_id = ?", + ) + .bind(approval_id) + .execute(&mut **transaction) + .await?; + return Ok(()); + } + if pin_count >= MAX_APPROVAL_DELIVERY_PINS + || pinned_bytes + .checked_add(snapshot_ciphertext_bytes) + .is_none_or(|bytes| bytes > MAX_PINNED_SNAPSHOT_CIPHERTEXT_BYTES) + { + return Err(ApprovalError::Capacity { + scope: "delivery_pins", + }); + } + sqlx::query( + "INSERT INTO approval_delivery_pins \ + (approval_id, ref_count, snapshot_ciphertext_bytes, created_at) VALUES (?, 1, ?, ?)", + ) + .bind(approval_id) + .bind(snapshot_ciphertext_bytes) + .bind(now) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn token_active_in( + transaction: &mut Transaction<'_, Sqlite>, + actor_api_token_id: &str, +) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM api_tokens WHERE id = ? AND revoked_at IS NULL)", + ) + .bind(actor_api_token_id) + .fetch_one(&mut **transaction) + .await + .map(|active| active != 0) +} + +async fn expire_pending_in( + transaction: &mut Transaction<'_, Sqlite>, + now: i64, +) -> Result { + sqlx::query( + "UPDATE approvals SET status = 'expired', revision = revision + 1, \ + updated_at = ?, completed_at = ? WHERE status = 'pending' AND expires_at <= ?", + ) + .bind(now) + .bind(now) + .bind(now) + .execute(&mut **transaction) + .await + .map(|result| result.rows_affected()) +} + +async fn reclaim_expired_correlations_in( + transaction: &mut Transaction<'_, Sqlite>, + now: i64, +) -> Result { + sqlx::query( + "DELETE FROM approval_correlations \ + WHERE expires_at IS NOT NULL AND expires_at <= ? \ + AND NOT EXISTS ( \ + SELECT 1 FROM approval_delivery_pins \ + WHERE approval_delivery_pins.approval_id = approval_correlations.approval_id \ + ) \ + AND NOT EXISTS ( \ + SELECT 1 FROM gateway_invocation_idempotency \ + WHERE gateway_invocation_idempotency.approval_correlation_id = approval_correlations.approval_id \ + AND (gateway_invocation_idempotency.expires_at IS NULL \ + OR gateway_invocation_idempotency.expires_at > ?) \ + ) \ + AND NOT EXISTS ( \ + SELECT 1 FROM gateway_invocation_idempotency \ + WHERE gateway_invocation_idempotency.state IN ('reserved', 'executing') \ + AND gateway_invocation_idempotency.owner_api_token_id = approval_correlations.actor_id \ + AND approval_correlations.actor_kind = 'api_token' \ + AND approval_correlations.call_id = 'gateway' \ + AND approval_correlations.execution_id = \ + 'gateway-idempotency:' || gateway_invocation_idempotency.id \ + )", + ) + .bind(now) + .bind(now) + .execute(&mut **transaction) + .await + .map(|result| result.rows_affected()) +} + +async fn enforce_retention(transaction: &mut Transaction<'_, Sqlite>) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT OR IGNORE INTO approval_terminal_order (approval_id) \ + SELECT id FROM approvals WHERE status IN ( \ + 'denied', 'expired', 'canceled', 'succeeded', 'failed', 'stale', 'interrupted' \ + ) ORDER BY completed_at, sequence", + ) + .execute(&mut **transaction) + .await?; + sqlx::query( + "DELETE FROM approvals WHERE id IN ( \ + SELECT approvals.id FROM approvals \ + JOIN approval_terminal_order ON approval_terminal_order.approval_id = approvals.id \ + WHERE NOT EXISTS (SELECT 1 FROM approval_delivery_pins \ + WHERE approval_delivery_pins.approval_id = approvals.id) \ + ORDER BY approval_terminal_order.sequence DESC LIMIT -1 OFFSET ? \ + )", + ) + .bind(TERMINAL_RETENTION) + .execute(&mut **transaction) + .await?; + sqlx::query( + "WITH terminal_usage AS ( \ + SELECT approvals.id, SUM( \ + length(arguments_ciphertext) + length(redacted_arguments_ciphertext) \ + + length(input_schema_ciphertext) + COALESCE(length(output_schema_ciphertext), 0) \ + + length(invocation_snapshot_ciphertext) + COALESCE(length(result_ciphertext), 0) \ + ) OVER (ORDER BY approval_terminal_order.sequence DESC \ + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS retained_bytes \ + FROM approvals JOIN approval_terminal_order \ + ON approval_terminal_order.approval_id = approvals.id \ + WHERE NOT EXISTS (SELECT 1 FROM approval_delivery_pins \ + WHERE approval_delivery_pins.approval_id = approvals.id) \ + ) DELETE FROM approvals WHERE id IN ( \ + SELECT id FROM terminal_usage WHERE retained_bytes > ? \ + )", + ) + .bind(MAX_TERMINAL_APPROVAL_CIPHERTEXT_BYTES) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn enforce_audit_retention( + transaction: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "DELETE FROM audit_events WHERE rowid IN (SELECT rowid FROM audit_events \ + ORDER BY rowid DESC LIMIT -1 OFFSET ?)", + ) + .bind(TERMINAL_RETENTION) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn classify_cas_failure( + transaction: &mut Transaction<'_, Sqlite>, + id: &str, + expected_revision: i64, +) -> Result { + let Some(row) = fetch_row_in(transaction, id).await? else { + return Ok(ApprovalError::NotFound); + }; + Ok(classify_row_cas_failure(&row, expected_revision)) +} + +fn classify_row_cas_failure(row: &ApprovalRow, expected_revision: i64) -> ApprovalError { + if row.revision != expected_revision { + ApprovalError::RevisionConflict { + expected: expected_revision, + actual: row.revision, + } + } else { + match row.status.parse() { + Ok(status) => ApprovalError::InvalidTransition { status }, + Err(error) => error, + } + } +} + +fn mode_provenance_str(provenance: ModeProvenance) -> &'static str { + match provenance { + ModeProvenance::ToolOverride => "tool_override", + ModeProvenance::SourceOverride => "source_override", + ModeProvenance::Intrinsic => "intrinsic", + } +} + +fn parse_mode_provenance(value: &str) -> Result { + match value { + "tool_override" => Ok(ModeProvenance::ToolOverride), + "source_override" => Ok(ModeProvenance::SourceOverride), + "intrinsic" => Ok(ModeProvenance::Intrinsic), + _ => Err(ApprovalError::CorruptData("unknown mode provenance")), + } +} + +fn validate_new(new: &NewApproval) -> Result<(), ApprovalError> { + validate_identifier("execution ID", &new.execution_id, 128)?; + validate_identifier("call ID", &new.call_id, 128)?; + validate_identifier("actor ID", &new.actor.id(), 128)?; + if new.surface == RequestSurface::Admin { + return Err(ApprovalError::Validation { + code: "invalid_approval_surface", + message: "admin requests cannot own tool approvals".to_owned(), + }); + } + if let Some(name) = new.actor.name_snapshot() { + validate_identifier("actor name snapshot", name, 200)?; + } + validate_identifier("source ID", &new.revisions.source_id, 128)?; + validate_identifier("tool ID", &new.revisions.tool_id, 128)?; + validate_identifier("callable path snapshot", &new.callable_path_snapshot, 512)?; + if let Some(name) = &new.source_display_name_snapshot { + validate_identifier("source display name snapshot", name, 300)?; + } + if let Some(name) = &new.tool_display_name_snapshot { + validate_identifier("tool display name snapshot", name, 300)?; + } + if [ + new.revisions.source_revision, + new.revisions.catalog_revision, + new.revisions.tool_revision, + new.revisions.binding_revision, + ] + .into_iter() + .any(|revision| revision < 0) + || new + .revisions + .credential_revision + .is_some_and(|revision| revision < 0) + { + return Err(ApprovalError::Validation { + code: "invalid_invocation_revision", + message: "invocation revisions cannot be negative".to_owned(), + }); + } + Ok(()) +} + +fn redact_arguments(arguments: &Value, schema: &Value) -> Value { + match (arguments, schema.get("type").and_then(Value::as_str)) { + (Value::Object(arguments), Some("object")) => { + let properties = schema.get("properties").and_then(Value::as_object); + Value::Object( + properties + .into_iter() + .flat_map(|properties| properties.iter()) + .filter_map(|(name, property_schema)| { + arguments + .get(name) + .map(|value| (name.clone(), redact_arguments(value, property_schema))) + }) + .collect(), + ) + } + (Value::Array(arguments), Some("array")) => { + let item_schema = schema.get("items").unwrap_or(&Value::Null); + Value::Array( + arguments + .iter() + .map(|value| redact_arguments(value, item_schema)) + .collect(), + ) + } + (Value::Null, _) => Value::Null, + _ => Value::String("[redacted]".to_owned()), + } +} + +fn validate_identifier(label: &'static str, value: &str, max: usize) -> Result<(), ApprovalError> { + if value.is_empty() || value.len() > max || value.contains('\0') { + return Err(ApprovalError::Validation { + code: "invalid_approval_metadata", + message: format!("{label} must contain between 1 and {max} safe bytes"), + }); + } + Ok(()) +} + +fn validate_failure_code(code: &str) -> Result<(), ApprovalError> { + validate_identifier("failure code", code, 128) +} + +fn encode_json(label: &'static str, value: &Value, limit: usize) -> Result, ApprovalError> { + let encoded = serde_json::to_vec(value)?; + if encoded.len() > limit { + return Err(ApprovalError::PayloadTooLarge { label, limit }); + } + Ok(encoded) +} + +fn canonical_json_bytes(value: &Value) -> Result, ApprovalError> { + fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.iter().map(canonicalize).collect()), + Value::Object(values) => { + let mut entries = values.iter().collect::>(); + entries.sort_unstable_by_key(|(key, _)| *key); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.clone(), canonicalize(value))) + .collect(), + ) + } + scalar => scalar.clone(), + } + } + + serde_json::to_vec(&canonicalize(value)).map_err(ApprovalError::Json) +} + +fn decrypt_json( + keyring: &Keyring, + purpose: &str, + id: &str, + ciphertext: &[u8], +) -> Result { + let plaintext = keyring.decrypt(purpose, id, ciphertext)?; + serde_json::from_slice(&plaintext).map_err(ApprovalError::Json) +} + +#[derive(Debug, Error)] +pub enum ApprovalError { + #[error("approval storage failed: {0}")] + Database(#[from] sqlx::Error), + #[error("approval protection failed: {0}")] + Crypto(#[from] CryptoError), + #[error("approval JSON failed: {0}")] + Json(#[from] serde_json::Error), + #[error("{message}")] + Validation { code: &'static str, message: String }, + #[error("approval {label} exceeds the {limit}-byte limit")] + PayloadTooLarge { label: &'static str, limit: usize }, + #[error("approval was not found")] + NotFound, + #[error("approval has expired")] + Expired, + #[error("the approval owner token is missing or revoked")] + OwnerTokenInactive, + #[error("the {scope} active approval capacity has been reached")] + Capacity { scope: &'static str }, + #[error("the execution call is already correlated with a different approval request")] + CorrelationConflict, + #[error("the execution call is correlated with an approval outside the retention window")] + CorrelationRetired, + #[error("approval worker generation does not match")] + WorkerGenerationConflict, + #[error("approval revision conflict: expected {expected}, found {actual}")] + RevisionConflict { expected: i64, actual: i64 }, + #[error("approval has already received a conflicting decision")] + DecisionConflict, + #[error("approval cannot transition from {status}")] + InvalidTransition { status: ApprovalStatus }, + #[error("stored approval data is invalid: {0}")] + CorruptData(&'static str), +} + +#[cfg(test)] +mod tests; diff --git a/src/approval/tests.rs b/src/approval/tests.rs new file mode 100644 index 000000000..d2dda65cf --- /dev/null +++ b/src/approval/tests.rs @@ -0,0 +1,1781 @@ +use std::sync::{ + Arc, + atomic::{AtomicI64, Ordering}, +}; + +use crate::{ + AppConfig, ExecutorApp, + actor::ToolActor, + catalog::{InvocationRevisionToken, ModeProvenance, RequestSurface}, + crypto::Keyring, +}; +use serde_json::json; + +use super::{ + ApprovalDecision, ApprovalError, ApprovalListQuery, ApprovalStatus, ApprovalStore, Clock, + ExecutionOutcome, NewApproval, +}; + +struct TestClock(AtomicI64); + +impl TestClock { + fn new(now: i64) -> Self { + Self(AtomicI64::new(now)) + } + + fn set(&self, now: i64) { + self.0.store(now, Ordering::SeqCst); + } +} + +impl Clock for TestClock { + fn now(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +async fn approval_store() -> ( + tempfile::TempDir, + ExecutorApp, + ApprovalStore, + Arc, +) { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "INSERT INTO admins (id, username, password_hash, created_at) VALUES (1, 'admin', 'hash', 1)", + ) + .execute(app.pool()) + .await + .expect("admin fixture should insert"); + sqlx::query( + "INSERT INTO api_tokens (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('token-1', 'Automation', ?, 'exe_test', 'last', 1)", + ) + .bind(vec![7_u8; 32]) + .execute(app.pool()) + .await + .expect("token fixture should insert"); + sqlx::query("UPDATE approval_clock SET effective_now = 0 WHERE id = 1") + .execute(app.pool()) + .await + .expect("test clock high-water should reset"); + let clock = Arc::new(TestClock::new(1_000)); + let store = ApprovalStore::new( + app.pool().clone(), + Keyring::random().expect("test keyring should generate"), + clock.clone(), + ); + (directory, app, store, clock) +} + +fn new_approval(execution_id: &str, call_id: &str) -> NewApproval { + NewApproval { + execution_id: execution_id.to_owned(), + call_id: call_id.to_owned(), + worker_generation: 0, + actor: ToolActor::api_token("token-1", Some("Automation".to_owned())), + surface: RequestSurface::Gateway, + callable_path_snapshot: "weather.delete_forecast".to_owned(), + source_display_name_snapshot: Some("Weather".to_owned()), + tool_display_name_snapshot: Some("Delete forecast".to_owned()), + mode_provenance: ModeProvenance::Intrinsic, + revisions: InvocationRevisionToken { + source_id: "source-1".to_owned(), + tool_id: "tool-1".to_owned(), + source_revision: 11, + catalog_revision: 12, + tool_revision: 13, + binding_revision: 14, + credential_revision: Some(15), + }, + arguments: json!({ "location": "secret-location" }), + input_schema: json!({ + "type": "object", + "required": ["location"], + "properties": { "location": { "type": "string" } } + }), + output_schema: Some(json!({ "type": "object" })), + invocation_snapshot: json!({ "binding": "exact-secret-binding" }), + } +} + +fn local_approval(execution_id: &str, call_id: &str, actor: ToolActor) -> NewApproval { + let mut approval = new_approval(execution_id, call_id); + approval.actor = actor; + approval.surface = RequestSurface::Cli; + approval +} + +async fn wait_for_terminal_log(app: &ExecutorApp, approval_id: &str, error_code: &str) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let (found, pending) = sqlx::query_as::<_, (i64, i64)>( + "SELECT \ + EXISTS(SELECT 1 FROM request_logs WHERE approval_id = ? AND error_code = ?), \ + EXISTS(SELECT 1 FROM approval_log_outbox WHERE approval_id = ?)", + ) + .bind(approval_id) + .bind(error_code) + .bind(approval_id) + .fetch_one(app.pool()) + .await + .expect("request log should read"); + if found != 0 && pending == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("terminal approval log {error_code} should be stored")); +} + +#[tokio::test] +async fn snapshots_are_encrypted_bounded_and_exposed_only_through_the_right_views() { + let (directory, app, store, _clock) = approval_store().await; + let created = store + .create(new_approval("execution-1", "call-1")) + .await + .expect("approval should persist"); + + assert_eq!(created.record.status, ApprovalStatus::Pending); + assert_eq!(created.record.expires_at, 1_600); + assert_eq!(created.record.revisions.source_revision, 11); + assert_eq!( + created.redacted_arguments, + json!({ "location": "[redacted]" }) + ); + assert_eq!(created.input_schema["required"], json!(["location"])); + assert!( + store + .get_for_token(&created.record.id, "another-token") + .await + .expect("owner lookup should read") + .is_none() + ); + + let approved = store + .decide( + &created.record.id, + "decision-snapshot", + created.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("snapshot approval should be accepted"); + let execution = store + .claim_execution(&created.record.id, 0, approved.record.revision) + .await + .expect("execution snapshot should be claimed"); + assert_eq!( + execution.arguments, + json!({ "location": "secret-location" }) + ); + assert_eq!(execution.output_schema, Some(json!({ "type": "object" }))); + assert_eq!( + execution.invocation_snapshot["binding"], + "exact-secret-binding" + ); + let stored = sqlx::query_as::<_, (Vec, Vec, Vec, Vec)>( + "SELECT arguments_ciphertext, redacted_arguments_ciphertext, \ + input_schema_ciphertext, invocation_snapshot_ciphertext FROM approvals WHERE id = ?", + ) + .bind(&created.record.id) + .fetch_one(app.pool()) + .await + .expect("ciphertexts should read"); + for ciphertext in [stored.0, stored.1, stored.2, stored.3] { + assert!( + !ciphertext + .windows(b"secret-location".len()) + .any(|part| part == b"secret-location") + ); + assert!( + !ciphertext + .windows(b"exact-secret-binding".len()) + .any(|part| part == b"exact-secret-binding") + ); + } + for name in ["executor.db", "executor.db-wal", "executor.db-shm"] { + let path = directory.path().join(name); + if path.exists() { + let bytes = std::fs::read(path).expect("SQLite storage should be readable"); + assert!( + !bytes + .windows(b"secret-location".len()) + .any(|part| part == b"secret-location") + ); + assert!( + !bytes + .windows(b"exact-secret-binding".len()) + .any(|part| part == b"exact-secret-binding") + ); + } + } + + let oversized = "x".repeat(8 * 1024 * 1024 + 1); + let mut approval = new_approval("execution-big", "call-big"); + approval.arguments = json!(oversized); + assert!(matches!( + store.create(approval).await, + Err(ApprovalError::PayloadTooLarge { + label: "arguments", + .. + }) + )); +} + +#[tokio::test] +async fn decisions_are_cas_guarded_idempotent_and_follow_the_legal_transition_graph() { + let (_directory, app, store, _clock) = approval_store().await; + let created = store + .create(new_approval("execution-2", "call-2")) + .await + .expect("approval should persist"); + let approved = store + .decide( + &created.record.id, + "decision-retry-with-new-id", + created.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should be accepted"); + assert!(!approved.idempotent); + assert_eq!(approved.record.status, ApprovalStatus::Approved); + + let replay = store + .decide( + &created.record.id, + "decision-1", + created.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("identical decision should replay"); + assert!(replay.idempotent); + assert_eq!(replay.record.revision, approved.record.revision); + let audits = sqlx::query_as::<_, (String, String, i64, String, String)>( + "SELECT request_id, action, actor_admin_id, target_path_snapshot, metadata_json \ + FROM audit_events WHERE action = 'approval.approve'", + ) + .fetch_all(app.pool()) + .await + .expect("approval audit should read"); + assert_eq!(audits.len(), 1); + assert_eq!(audits[0].0, "decision-retry-with-new-id"); + assert_eq!(audits[0].1, "approval.approve"); + assert_eq!(audits[0].2, 1); + assert_eq!(audits[0].3, "weather.delete_forecast"); + let metadata: serde_json::Value = + serde_json::from_str(&audits[0].4).expect("audit metadata should be JSON"); + assert_eq!(metadata["approvalId"], created.record.id); + assert_eq!(metadata["from"], "pending"); + assert_eq!(metadata["to"], "approved"); + assert_eq!(metadata["revision"], 1); + assert!(!audits[0].4.contains("secret-location")); + assert!(matches!( + store + .decide( + &created.record.id, + "decision-2", + approved.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await, + Err(ApprovalError::DecisionConflict) + )); + + let executing = store + .claim_execution(&created.record.id, 0, approved.record.revision) + .await + .expect("approved call should enter executing"); + assert!(matches!( + store + .cancel_token_owner(&created.record.id, "token-1", executing.record.revision) + .await, + Err(ApprovalError::InvalidTransition { + status: ApprovalStatus::Executing + }) + )); + let completed = store + .finish( + &created.record.id, + executing.record.revision, + ExecutionOutcome::Succeeded, + &json!({ "ok": true, "secretResult": "private" }), + None, + ) + .await + .expect("execution should complete"); + assert_eq!(completed.status, ApprovalStatus::Succeeded); + let terminal_replay = store + .decide( + &created.record.id, + "decision-after-completion", + 0, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("the same decision should remain idempotent after completion"); + assert!(terminal_replay.idempotent); + assert_eq!(terminal_replay.record.status, ApprovalStatus::Succeeded); + let owner = store + .get_for_token(&created.record.id, "token-1") + .await + .expect("owner lookup should read") + .expect("owner lookup should exist"); + assert_eq!( + owner.result, + Some(json!({ "ok": true, "secretResult": "private" })) + ); + let admin = store + .get_admin(&created.record.id) + .await + .expect("admin lookup should read") + .expect("admin lookup should exist"); + let serialized = serde_json::to_string(&admin).expect("admin detail should serialize"); + assert!(!serialized.contains("secretResult")); + assert!(!serialized.contains("private")); +} + +#[tokio::test] +async fn owner_cancellation_is_idempotent_and_decision_races_have_one_winner() { + let (_directory, _app, store, _clock) = approval_store().await; + let canceled = store + .create(new_approval("execution-cancel", "call-cancel")) + .await + .expect("cancelable approval should persist"); + let duplicate = store + .create(new_approval("execution-cancel", "call-cancel")) + .await + .expect("identical duplicate should recover the approval"); + assert!(duplicate.reused); + assert_eq!(duplicate.record.id, canceled.record.id); + let canceled = store + .cancel_token_owner(&canceled.record.id, "token-1", 0) + .await + .expect("owner cancellation should succeed"); + let replay = store + .cancel_token_owner(&canceled.id, "token-1", 0) + .await + .expect("owner cancellation replay should be idempotent"); + assert_eq!(replay.status, ApprovalStatus::Canceled); + assert_eq!(replay.revision, canceled.revision); + + let raced = store + .create(new_approval("execution-race", "call-race")) + .await + .expect("raced approval should persist"); + let approve_store = store.clone(); + let approve_id = raced.record.id.clone(); + let approve = tokio::spawn(async move { + approve_store + .decide(&approve_id, "race-approve", 0, ApprovalDecision::Approve, 1) + .await + }); + let deny_store = store.clone(); + let deny_id = raced.record.id; + let deny = tokio::spawn(async move { + deny_store + .decide(&deny_id, "race-deny", 0, ApprovalDecision::Deny, 1) + .await + }); + let outcomes = [ + approve.await.expect("approve task should join"), + deny.await.expect("deny task should join"), + ]; + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Err(ApprovalError::DecisionConflict))) + .count(), + 1 + ); +} + +#[tokio::test] +async fn concurrent_identical_creates_recover_one_correlated_approval() { + let (_directory, app, store, _clock) = approval_store().await; + let first_store = store.clone(); + let second_store = store.clone(); + let (first, second) = tokio::join!( + first_store.create(new_approval("execution-create-race", "call-create-race")), + second_store.create(new_approval("execution-create-race", "call-create-race")), + ); + let first = first.expect("first concurrent create should resolve"); + let second = second.expect("second concurrent create should resolve"); + + assert_eq!(first.record.id, second.record.id); + assert_ne!(first.reused, second.reused); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE execution_id = ? AND call_id = ?", + ) + .bind("execution-create-race") + .bind("call-create-race") + .fetch_one(app.pool()) + .await + .expect("approval count should read"), + 1 + ); +} + +#[tokio::test] +async fn duplicate_correlation_requires_matching_request_identity_without_catalog_revisions() { + let (_directory, _app, store, _clock) = approval_store().await; + let created = store + .create(new_approval("execution-correlation", "call-correlation")) + .await + .expect("approval should persist"); + + let mut catalog_changed = new_approval("execution-correlation", "call-correlation"); + catalog_changed.revisions.source_revision += 100; + catalog_changed.revisions.catalog_revision += 100; + catalog_changed.revisions.tool_revision += 100; + catalog_changed.revisions.binding_revision += 100; + catalog_changed.revisions.credential_revision = Some(500); + let recovered = store + .create(catalog_changed) + .await + .expect("catalog changes should not break caller retry correlation"); + assert!(recovered.reused); + assert_eq!(recovered.record.id, created.record.id); + + let mut different_arguments = new_approval("execution-correlation", "call-correlation"); + different_arguments.arguments = json!({ "location": "different" }); + assert!(matches!( + store.create(different_arguments).await, + Err(ApprovalError::CorrelationConflict) + )); + + let mut different_path = new_approval("execution-correlation", "call-correlation"); + different_path.callable_path_snapshot = "weather.create_forecast".to_owned(); + assert!(matches!( + store.create(different_path).await, + Err(ApprovalError::CorrelationConflict) + )); + + let mut different_surface = new_approval("execution-correlation", "call-correlation"); + different_surface.surface = RequestSurface::Mcp; + assert!(matches!( + store.create(different_surface).await, + Err(ApprovalError::CorrelationConflict) + )); + + let mut different_generation = new_approval("execution-correlation", "call-correlation"); + different_generation.worker_generation += 1; + assert!(matches!( + store.create(different_generation).await, + Err(ApprovalError::CorrelationConflict) + )); +} + +#[tokio::test] +async fn duplicate_correlation_is_actor_typed_and_never_returns_another_actors_record() { + let (_directory, app, store, _clock) = approval_store().await; + sqlx::query( + "INSERT INTO api_tokens (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('1', 'Other actor', ?, 'exe_other', 'last', 1)", + ) + .bind(vec![8_u8; 32]) + .execute(app.pool()) + .await + .expect("second token fixture should insert"); + let mut original = new_approval("execution-cross-actor", "call-cross-actor"); + original.actor = ToolActor::admin(1); + let created = store + .create(original) + .await + .expect("admin approval should persist"); + let mut cross_actor = new_approval("execution-cross-actor", "call-cross-actor"); + cross_actor.actor = ToolActor::api_token("1", Some("Other actor".to_owned())); + let cross_actor = store + .create(cross_actor) + .await + .expect("another actor should have an isolated correlation namespace"); + assert!(!cross_actor.reused); + assert_ne!(cross_actor.record.id, created.record.id); + assert!( + store + .get_for_actor( + &created.record.id, + &ToolActor::api_token("1", Some("Other actor".to_owned())), + ) + .await + .expect("cross-actor lookup should read") + .is_none() + ); + assert!( + store + .get_for_actor(&cross_actor.record.id, &ToolActor::admin(1)) + .await + .expect("reverse cross-actor lookup should read") + .is_none() + ); +} + +#[tokio::test] +async fn duplicate_correlation_uses_canonical_argument_object_order() { + let (_directory, _app, store, _clock) = approval_store().await; + let mut first = new_approval("execution-canonical", "call-canonical"); + first.arguments = + serde_json::from_str(r#"{"b":2,"a":{"y":2,"x":1}}"#).expect("first arguments should parse"); + let created = store.create(first).await.expect("approval should persist"); + let mut reordered = new_approval("execution-canonical", "call-canonical"); + reordered.arguments = serde_json::from_str(r#"{"a":{"x":1,"y":2},"b":2}"#) + .expect("reordered arguments should parse"); + let recovered = store + .create(reordered) + .await + .expect("equivalent arguments should recover the approval"); + + assert!(recovered.reused); + assert_eq!(recovered.record.id, created.record.id); + + let mut integer = new_approval("execution-number-type", "call-number-type"); + integer.arguments = + serde_json::from_str(r#"{"value":1}"#).expect("integer arguments should parse"); + store + .create(integer) + .await + .expect("integer approval should persist"); + let mut decimal = new_approval("execution-number-type", "call-number-type"); + decimal.arguments = + serde_json::from_str(r#"{"value":1.0}"#).expect("decimal arguments should parse"); + assert!(matches!( + store.create(decimal).await, + Err(ApprovalError::CorrelationConflict) + )); +} + +#[tokio::test] +async fn retained_correlation_prevents_recreation_after_approval_payload_retention() { + let (_directory, app, store, _clock) = approval_store().await; + let created = store + .create(new_approval("execution-retired", "call-retired")) + .await + .expect("approval should persist"); + store + .decide( + &created.record.id, + "deny-before-payload-retention", + created.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("approval should become terminal before payload retention"); + sqlx::query("DELETE FROM approvals WHERE id = ?") + .bind(&created.record.id) + .execute(app.pool()) + .await + .expect("retention simulation should remove approval payload"); + + assert!(matches!( + store + .create(new_approval("execution-retired", "call-retired")) + .await, + Err(ApprovalError::CorrelationRetired) + )); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE actor_kind = 'api_token' AND actor_id = 'token-1' \ + AND execution_id = ? AND call_id = ?", + ) + .bind("execution-retired") + .bind("call-retired") + .fetch_one(app.pool()) + .await + .expect("approval count should read"), + 0 + ); +} + +#[tokio::test] +async fn terminal_correlation_is_retained_for_the_full_ttl_then_reclaimed() { + let (_directory, app, store, clock) = approval_store().await; + clock.set(2_000); + let created = store + .create(new_approval("execution-terminal-ttl", "call-terminal-ttl")) + .await + .expect("approval should persist"); + store + .decide( + &created.record.id, + "deny-terminal-ttl", + created.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("approval should become terminal"); + let expires_at = sqlx::query_scalar::<_, i64>( + "SELECT expires_at FROM approval_correlations WHERE approval_id = ?", + ) + .bind(&created.record.id) + .fetch_one(app.pool()) + .await + .expect("correlation expiry should read"); + assert_eq!(expires_at, 2_000 + super::APPROVAL_CORRELATION_TTL_SECONDS); + + clock.set(expires_at - 1); + let early_retry = store + .create(new_approval("execution-terminal-ttl", "call-terminal-ttl")) + .await + .expect("retry before correlation expiry should recover the approval"); + assert!(early_retry.reused); + assert_eq!(early_retry.record.id, created.record.id); + + clock.set(expires_at); + let reused_call = store + .create(new_approval("execution-terminal-ttl", "call-terminal-ttl")) + .await + .expect("call at correlation expiry should start a new approval"); + assert!(!reused_call.reused); + assert_ne!(reused_call.record.id, created.record.id); + assert!( + store + .get_admin(&created.record.id) + .await + .expect("retired approval lookup should read") + .is_none() + ); +} + +#[tokio::test] +async fn expired_terminal_correlation_waits_for_delivery_pin_release_before_reclamation() { + let (_directory, app, store, clock) = approval_store().await; + clock.set(3_000); + let mut request = new_approval("execution-pinned-correlation", "call-pinned-correlation"); + request.worker_generation = 7; + let created = store + .create(request) + .await + .expect("pinned approval should persist"); + store + .decide( + &created.record.id, + "deny-pinned-correlation", + created.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("pinned approval should become terminal"); + let expires_at = sqlx::query_scalar::<_, i64>( + "SELECT expires_at FROM approval_correlations WHERE approval_id = ?", + ) + .bind(&created.record.id) + .fetch_one(app.pool()) + .await + .expect("pinned correlation expiry should read"); + + clock.set(expires_at); + store + .create(new_approval( + "execution-pinned-cleanup-before", + "call-pinned-cleanup-before", + )) + .await + .expect("cleanup should skip the pinned terminal correlation"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approval_correlations WHERE approval_id = ?", + ) + .bind(&created.record.id) + .fetch_one(app.pool()) + .await + .expect("pinned correlation should read"), + 1 + ); + + store + .release_delivery_pin(&super::ApprovalDeliveryIdentity { + approval_id: created.record.id.clone(), + actor: ToolActor::api_token("token-1", Some("Automation".to_owned())), + execution_id: created.record.execution_id.clone(), + call_id: created.record.call_id.clone(), + }) + .await + .expect("delivery pin should release after terminal result delivery"); + store + .create(new_approval( + "execution-pinned-cleanup-after", + "call-pinned-cleanup-after", + )) + .await + .expect("cleanup should reclaim the unpinned expired correlation"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approval_correlations WHERE approval_id = ?", + ) + .bind(&created.record.id) + .fetch_one(app.pool()) + .await + .expect("reclaimed correlation should read"), + 0 + ); + assert!( + store + .get_admin(&created.record.id) + .await + .expect("reclaimed approval should read") + .is_none() + ); +} + +#[tokio::test] +async fn nonterminal_correlation_never_expires_or_gets_reclaimed() { + let (_directory, app, store, clock) = approval_store().await; + let created = store + .create(new_approval( + "execution-active-correlation", + "call-active-correlation", + )) + .await + .expect("approval should persist"); + let approved = store + .decide( + &created.record.id, + "approve-active-correlation", + created.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should remain nonterminal"); + assert!( + sqlx::query_scalar::<_, Option>( + "SELECT expires_at FROM approval_correlations WHERE approval_id = ?", + ) + .bind(&approved.record.id) + .fetch_one(app.pool()) + .await + .expect("active correlation expiry should read") + .is_none() + ); + + clock.set(1_000 + super::APPROVAL_CORRELATION_TTL_SECONDS * 2); + store + .create(new_approval( + "execution-active-cleanup-trigger", + "call-active-cleanup-trigger", + )) + .await + .expect("unrelated create should run correlation cleanup"); + let retry = store + .create(new_approval( + "execution-active-correlation", + "call-active-correlation", + )) + .await + .expect("active correlation should remain recoverable"); + assert!(retry.reused); + assert_eq!(retry.record.id, approved.record.id); +} + +#[tokio::test] +async fn live_gateway_idempotency_retention_extends_correlation_protection() { + let (_directory, app, store, clock) = approval_store().await; + let created = store + .create(new_approval( + "execution-idempotency-guard", + "call-idempotency-guard", + )) + .await + .expect("approval should persist"); + store + .decide( + &created.record.id, + "deny-idempotency-guard", + created.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("approval should become terminal"); + let correlation_expiry = 1_000 + super::APPROVAL_CORRELATION_TTL_SECONDS; + let idempotency_expiry = correlation_expiry + 60; + sqlx::query( + "INSERT INTO gateway_invocation_idempotency ( \ + id, owner_api_token_id, key_digest, request_digest, state, approval_id, \ + created_at, updated_at, completed_at, expires_at \ + ) VALUES (?, 'token-1', ?, ?, 'indeterminate', ?, 1000, 1000, 1000, ?)", + ) + .bind("idempotency-correlation-guard") + .bind(vec![3_u8; 32]) + .bind(vec![4_u8; 32]) + .bind(&created.record.id) + .bind(idempotency_expiry) + .execute(app.pool()) + .await + .expect("idempotency retention fixture should insert"); + + clock.set(correlation_expiry); + let protected = store + .create(new_approval( + "execution-idempotency-guard", + "call-idempotency-guard", + )) + .await + .expect("live idempotency should preserve the correlation"); + assert!(protected.reused); + assert_eq!(protected.record.id, created.record.id); + + clock.set(idempotency_expiry); + let reclaimed = store + .create(new_approval( + "execution-idempotency-guard", + "call-idempotency-guard", + )) + .await + .expect("expired idempotency should release the correlation"); + assert!(!reclaimed.reused); + assert_ne!(reclaimed.record.id, created.record.id); +} + +#[tokio::test] +async fn reserved_gateway_idempotency_linkage_prevents_correlation_reclamation() { + let (_directory, app, store, clock) = approval_store().await; + let mut approval = new_approval("gateway-idempotency:reserved-correlation-guard", "gateway"); + approval.worker_generation = 0; + let created = store + .create(approval.clone()) + .await + .expect("approval should persist"); + store + .decide( + &created.record.id, + "deny-reserved-correlation-guard", + created.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("approval should become terminal"); + sqlx::query( + "INSERT INTO gateway_invocation_idempotency ( \ + id, owner_api_token_id, key_digest, request_digest, state, \ + created_at, updated_at \ + ) VALUES (?, 'token-1', ?, ?, 'reserved', 1000, 1000)", + ) + .bind("reserved-correlation-guard") + .bind(vec![5_u8; 32]) + .bind(vec![6_u8; 32]) + .execute(app.pool()) + .await + .expect("reserved idempotency fixture should insert"); + + clock.set(1_000 + super::APPROVAL_CORRELATION_TTL_SECONDS); + let protected = store + .create(approval.clone()) + .await + .expect("reserved idempotency should preserve its execution-linked correlation"); + assert!(protected.reused); + assert_eq!(protected.record.id, created.record.id); + + sqlx::query("DELETE FROM gateway_invocation_idempotency WHERE id = ?") + .bind("reserved-correlation-guard") + .execute(app.pool()) + .await + .expect("reserved idempotency fixture should clear"); + let reclaimed = store + .create(approval) + .await + .expect("released reservation should permit correlation reclamation"); + assert!(!reclaimed.reused); + assert_ne!(reclaimed.record.id, created.record.id); +} + +#[tokio::test] +async fn correlation_capacity_fails_closed_without_blocking_existing_retries() { + let (_directory, app, store, _clock) = approval_store().await; + let existing = store + .create(new_approval("execution-cap-existing", "call-cap-existing")) + .await + .expect("existing correlation should persist"); + sqlx::query("UPDATE approval_correlation_state SET correlation_count = 100000 WHERE id = 1") + .execute(app.pool()) + .await + .expect("correlation capacity fixture should update"); + + let recovered = store + .create(new_approval("execution-cap-existing", "call-cap-existing")) + .await + .expect("existing correlation should remain recoverable at capacity"); + assert!(recovered.reused); + assert_eq!(recovered.record.id, existing.record.id); + assert!(matches!( + store + .create(new_approval("execution-cap-new", "call-cap-new")) + .await, + Err(ApprovalError::Capacity { + scope: "correlations" + }) + )); +} + +#[tokio::test] +async fn expired_terminal_correlation_recovers_capacity_before_new_insert() { + let (_directory, app, store, clock) = approval_store().await; + let created = store + .create(new_approval("execution-cap-reclaim", "call-cap-reclaim")) + .await + .expect("approval should persist"); + store + .decide( + &created.record.id, + "deny-cap-reclaim", + created.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("approval should become terminal"); + sqlx::query("UPDATE approval_correlation_state SET correlation_count = 100000 WHERE id = 1") + .execute(app.pool()) + .await + .expect("correlation capacity fixture should update"); + clock.set(1_000 + super::APPROVAL_CORRELATION_TTL_SECONDS); + + let created_after_reclaim = store + .create(new_approval( + "execution-cap-after-reclaim", + "call-cap-after-reclaim", + )) + .await + .expect("expired terminal correlation should reclaim capacity first"); + assert!(!created_after_reclaim.reused); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT correlation_count FROM approval_correlation_state WHERE id = 1", + ) + .fetch_one(app.pool()) + .await + .expect("correlation count should read"), + 100_000 + ); +} + +#[tokio::test] +async fn terminal_log_ids_are_unique_for_sibling_calls_in_one_execution() { + let (_directory, _app, store, _clock) = approval_store().await; + let first = store + .create(new_approval("shared-execution", "first-call")) + .await + .expect("first sibling approval should persist"); + let second = store + .create(new_approval("shared-execution", "second-call")) + .await + .expect("second sibling approval should persist"); + for (index, approval) in [first, second].into_iter().enumerate() { + store + .decide( + &approval.record.id, + &format!("sibling-deny-{index}"), + approval.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("sibling approval should be denied"); + } + let events = store + .log_outbox(10) + .await + .expect("approval log outbox should read"); + let unique_ids = events + .iter() + .map(|event| event.request_id.as_str()) + .collect::>(); + assert_eq!(events.len(), 2); + assert_eq!(unique_ids.len(), 2); +} + +#[tokio::test] +async fn token_activity_caps_and_monotonic_clock_are_enforced_at_storage_boundaries() { + let (_directory, app, store, clock) = approval_store().await; + clock.set(5_000); + let first = store + .create(new_approval("execution-clock-1", "call-clock-1")) + .await + .expect("first clock approval should persist"); + clock.set(100); + let second = store + .create(new_approval("execution-clock-2", "call-clock-2")) + .await + .expect("backward clock approval should persist"); + assert_eq!(first.record.created_at, 5_000); + assert_eq!(second.record.created_at, 5_000); + assert_eq!(second.record.expires_at, 5_600); + clock.set(5_599); + assert_eq!(store.expire_pending().await.expect("expiry should run"), 0); + clock.set(5_600); + assert_eq!(store.expire_pending().await.expect("expiry should run"), 2); + + clock.set(6_000); + let approved = store + .create(new_approval("execution-revoke-race", "call-revoke-race")) + .await + .expect("approval should persist"); + let approved = store + .decide( + &approved.record.id, + "approve-before-revoke", + 0, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should be accepted"); + assert!(matches!( + store + .claim_execution(&approved.record.id, 4, approved.record.revision,) + .await, + Err(ApprovalError::WorkerGenerationConflict) + )); + sqlx::query("UPDATE api_tokens SET revoked_at = 6_001 WHERE id = 'token-1'") + .execute(app.pool()) + .await + .expect("token should revoke"); + assert!(matches!( + store + .claim_execution(&approved.record.id, 0, approved.record.revision,) + .await, + Err(ApprovalError::OwnerTokenInactive) + )); + assert!(matches!( + store + .create(new_approval("execution-after-revoke", "call-after-revoke")) + .await, + Err(ApprovalError::OwnerTokenInactive) + )); + + sqlx::query("UPDATE api_tokens SET revoked_at = NULL WHERE id = 'token-1'") + .execute(app.pool()) + .await + .expect("token should reactivate for capacity fixture"); + store + .cancel_token("token-1") + .await + .expect("active approvals should clear"); + for index in 0..128 { + store + .create(new_approval( + &format!("execution-cap-{index}"), + &format!("call-cap-{index}"), + )) + .await + .expect("approval below the owner cap should persist"); + } + assert!(matches!( + store + .create(new_approval("execution-over-cap", "call-over-cap")) + .await, + Err(ApprovalError::Capacity { scope: "owner" }) + )); + clock.set(6_600); + store + .create(new_approval( + "execution-after-cap-expiry", + "call-after-cap-expiry", + )) + .await + .expect("create should reclaim expired capacity in the same transaction"); +} + +#[tokio::test] +async fn local_actors_are_isolated_from_gateway_ownership_and_token_revocation() { + let (_directory, app, store, _clock) = approval_store().await; + let admin = store + .create(local_approval( + "execution-admin", + "call-admin", + ToolActor::admin(1), + )) + .await + .expect("admin-owned approval should persist without a synthetic token"); + let system = store + .create(local_approval( + "execution-system", + "call-system", + ToolActor::local_cli(), + )) + .await + .expect("system-owned approval should persist without a synthetic token"); + let token = store + .create(new_approval("execution-token", "call-token")) + .await + .expect("token-owned approval should persist"); + + assert_eq!(admin.record.actor_kind, crate::actor::ActorKind::Admin); + assert_eq!(admin.record.actor_api_token_id, None); + assert_eq!(system.record.actor_kind, crate::actor::ActorKind::System); + assert_eq!(system.record.actor_api_token_id, None); + assert!( + store + .get_for_token(&admin.record.id, "token-1") + .await + .expect("gateway lookup should read") + .is_none() + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM api_tokens") + .fetch_one(app.pool()) + .await + .expect("token count should read"), + 1 + ); + + assert!( + store + .revoke_owner_token("token-1") + .await + .expect("token revocation should run") + ); + assert_eq!( + store + .get_admin(&token.record.id) + .await + .expect("token approval should read") + .expect("token approval should exist") + .record + .status, + ApprovalStatus::Canceled + ); + for local in [&admin, &system] { + assert_eq!( + store + .get_admin(&local.record.id) + .await + .expect("local approval should read") + .expect("local approval should exist") + .record + .status, + ApprovalStatus::Pending + ); + } + + let approved = store + .decide( + &admin.record.id, + "approve-admin", + admin.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("admin-owned approval should be approved"); + store + .claim_execution(&admin.record.id, 0, approved.record.revision) + .await + .expect("admin-owned approval should execute without a token check"); + let denied = store + .decide( + &system.record.id, + "deny-system", + system.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("system-owned approval should be denied"); + assert_eq!(denied.record.status, ApprovalStatus::Denied); + assert!( + store + .log_outbox(256) + .await + .expect("outbox should read") + .iter() + .any(|event| { + event.approval_id.as_deref() == Some(system.record.id.as_str()) + && event.actor_api_token_id.is_none() + }) + ); +} + +#[tokio::test] +async fn aggregate_ciphertext_caps_bound_active_and_terminal_disk_usage() { + let (_directory, _app, store, _clock) = approval_store().await; + let large = "x".repeat(7 * 1024 * 1024); + let mut ids = Vec::new(); + for index in 0..4 { + let mut approval = new_approval( + &format!("execution-bytes-{index}"), + &format!("call-bytes-{index}"), + ); + approval.arguments = json!({ "location": large }); + approval.invocation_snapshot = json!(large); + let created = store + .create(approval) + .await + .expect("approval below aggregate byte cap should persist"); + ids.push(created.record.id); + } + let mut over_cap = new_approval("execution-bytes-over", "call-bytes-over"); + over_cap.arguments = json!({ "location": large }); + over_cap.invocation_snapshot = json!(large); + assert!(matches!( + store.create(over_cap).await, + Err(ApprovalError::Capacity { + scope: "active_bytes" + }) + )); + + for (index, id) in ids.iter().enumerate().skip(1) { + store + .decide( + id, + &format!("deny-bytes-{index}"), + 0, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("large approval should be denied"); + } + let mut newest = new_approval("execution-bytes-newest", "call-bytes-newest"); + newest.arguments = json!({ "location": large }); + newest.invocation_snapshot = json!(large); + let newest = store + .create(newest) + .await + .expect("terminal rows should not consume active byte capacity"); + store + .decide( + &newest.record.id, + "deny-bytes-newest", + 0, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("newest large approval should be denied"); + store + .decide( + &ids[0], + "deny-old-active-last", + 0, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("old active approval should be retained when it terminalizes last"); + assert!( + store + .get_admin(&ids[1]) + .await + .expect("oldest approval lookup should read") + .is_none() + ); + assert!( + store + .get_admin(&ids[0]) + .await + .expect("newly terminal old approval should read") + .is_some() + ); + assert!( + store + .get_admin(&newest.record.id) + .await + .expect("newest approval lookup should read") + .is_some() + ); + let outbox = store + .log_outbox(256) + .await + .expect("terminal log outbox should read"); + assert!( + outbox + .iter() + .any(|event| event.approval_id.as_deref() == Some(ids[1].as_str())), + "terminal logging metadata must survive approval retention" + ); +} + +#[tokio::test] +async fn delivery_pins_preserve_sixteen_large_results_until_each_waiter_copies_its_own_result() { + let (_directory, app, store, _clock) = approval_store().await; + let payload = "r".repeat(super::MAX_APPROVAL_RESULT_BYTES - 512); + let actor = ToolActor::api_token("token-1", Some("Automation".to_owned())); + let mut prepared = Vec::new(); + + for index in 0..16 { + let mut request = new_approval( + &format!("execution-large-result-{index}"), + &format!("call-large-result-{index}"), + ); + request.worker_generation = 9; + let pending = store + .create(request) + .await + .expect("pinned approval should persist"); + let approved = store + .decide( + &pending.record.id, + &format!("approve-large-result-{index}"), + pending.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("large result approval should be approved"); + let executing = store + .claim_execution(&pending.record.id, 9, approved.record.revision) + .await + .expect("large result approval should execute"); + let result = json!({ "index": index, "payload": payload }); + prepared.push((pending.record, executing.record.revision, result)); + } + + let barrier = Arc::new(tokio::sync::Barrier::new(prepared.len() + 1)); + let mut completions = Vec::new(); + for (record, revision, result) in prepared { + let finish_store = store.clone(); + let finish_barrier = barrier.clone(); + completions.push(tokio::spawn(async move { + finish_barrier.wait().await; + finish_store + .finish( + &record.id, + revision, + ExecutionOutcome::Succeeded, + &result, + None, + ) + .await + .expect("concurrent large result should become terminal while pinned"); + (record, result) + })); + } + barrier.wait().await; + let mut completed = Vec::new(); + for completion in completions { + completed.push( + completion + .await + .expect("concurrent large result task should join"), + ); + } + + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM approval_delivery_pins") + .fetch_one(app.pool()) + .await + .expect("delivery pin count should read"), + 16 + ); + for (record, expected) in &completed { + let copied = store + .get_for_actor(&record.id, &actor) + .await + .expect("pinned result should read") + .expect("pinned result must not be retained away"); + assert_eq!(copied.result.as_ref(), Some(expected)); + } + for (record, _) in &completed { + store + .release_delivery_pin(&super::ApprovalDeliveryIdentity { + approval_id: record.id.clone(), + actor: actor.clone(), + execution_id: record.execution_id.clone(), + call_id: record.call_id.clone(), + }) + .await + .expect("copied result should become reclaimable"); + } + + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM approval_delivery_pins") + .fetch_one(app.pool()) + .await + .expect("delivery pins should read"), + 0 + ); + let retained_bytes = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(length(arguments_ciphertext) \ + + length(redacted_arguments_ciphertext) + length(input_schema_ciphertext) \ + + COALESCE(length(output_schema_ciphertext), 0) \ + + length(invocation_snapshot_ciphertext) + COALESCE(length(result_ciphertext), 0)), 0) \ + FROM approvals WHERE status IN \ + ('denied', 'expired', 'canceled', 'succeeded', 'failed', 'stale', 'interrupted')", + ) + .fetch_one(app.pool()) + .await + .expect("terminal ciphertext usage should read"); + assert!(retained_bytes <= super::MAX_TERMINAL_APPROVAL_CIPHERTEXT_BYTES); + let mut evicted = 0; + for (record, _) in &completed { + if store + .get_admin(&record.id) + .await + .expect("reclaimed approval lookup should read") + .is_none() + { + evicted += 1; + } + } + assert!(evicted > 0); +} + +#[tokio::test] +async fn delivery_pin_retries_are_refcounted_bounded_and_cleared_by_recovery() { + let (_directory, app, store, _clock) = approval_store().await; + let actor = ToolActor::api_token("token-1", Some("Automation".to_owned())); + let mut retry_request = new_approval("execution-pin-retry", "call-pin-retry"); + retry_request.worker_generation = 1; + let retried = store + .create(retry_request.clone()) + .await + .expect("initial pinned approval should persist"); + store + .create(retry_request) + .await + .expect("duplicate retry should acquire another delivery reference"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT ref_count FROM approval_delivery_pins WHERE approval_id = ?", + ) + .bind(&retried.record.id) + .fetch_one(app.pool()) + .await + .expect("delivery refcount should read"), + 2 + ); + let retry_identity = super::ApprovalDeliveryIdentity { + approval_id: retried.record.id.clone(), + actor: actor.clone(), + execution_id: retried.record.execution_id.clone(), + call_id: retried.record.call_id.clone(), + }; + assert!( + store + .release_delivery_pin(&retry_identity) + .await + .expect("first retry reference should release") + ); + assert!( + store + .release_delivery_pin(&retry_identity) + .await + .expect("second retry reference should release") + ); + let mut pinned = Vec::new(); + for index in 0..super::MAX_APPROVAL_DELIVERY_PINS { + let mut request = new_approval( + &format!("execution-pin-cap-{index}"), + &format!("call-pin-cap-{index}"), + ); + request.worker_generation = 1; + let created = store + .create(request) + .await + .expect("approval below delivery pin cap should persist"); + store + .decide( + &created.record.id, + &format!("deny-pin-cap-{index}"), + created.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("denial should free active approval capacity"); + pinned.push(created.record); + } + let mut over_cap = new_approval("execution-pin-over-cap", "call-pin-over-cap"); + over_cap.worker_generation = 1; + assert!(matches!( + store.create(over_cap).await, + Err(ApprovalError::Capacity { + scope: "delivery_pins" + }) + )); + + let first = &pinned[0]; + let identity = super::ApprovalDeliveryIdentity { + approval_id: first.id.clone(), + actor, + execution_id: first.execution_id.clone(), + call_id: first.call_id.clone(), + }; + assert!( + store + .release_delivery_pin(&identity) + .await + .expect("pin releases") + ); + assert!( + !store + .release_delivery_pin(&identity) + .await + .expect("release replays") + ); + + store + .recover_startup() + .await + .expect("startup recovery should clear stale continuation pins"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM approval_delivery_pins") + .fetch_one(app.pool()) + .await + .expect("delivery pins should read"), + 0 + ); +} + +#[tokio::test] +async fn expiry_token_revocation_and_startup_recovery_are_durable() { + let (_directory, _app, store, clock) = approval_store().await; + let expiring = store + .create(new_approval("execution-expire", "call-expire")) + .await + .expect("expiring approval should persist"); + clock.set(1_600); + assert_eq!(store.expire_pending().await.expect("expiry should run"), 1); + assert_eq!( + store + .get_admin(&expiring.record.id) + .await + .expect("approval should read") + .expect("approval should exist") + .record + .status, + ApprovalStatus::Expired + ); + assert!(matches!( + store + .decide( + &expiring.record.id, + "decision-too-late", + expiring.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await, + Err(ApprovalError::Expired) + )); + + clock.set(2_000); + let mut interrupted_request = new_approval("execution-interrupted", "call-interrupted"); + interrupted_request.worker_generation = 3; + let interrupted = store + .create(interrupted_request) + .await + .expect("interrupted approval should persist"); + let interrupted = store + .decide( + &interrupted.record.id, + "decision-interrupted", + 0, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should be accepted"); + let interrupted = store + .claim_execution(&interrupted.record.id, 3, interrupted.record.revision) + .await + .expect("approval should execute"); + let mut orphan_pending_request = + new_approval("execution-orphan-pending", "call-orphan-pending"); + orphan_pending_request.worker_generation = 3; + let orphan_pending = store + .create(orphan_pending_request) + .await + .expect("orphan pending approval should persist"); + let mut orphan_approved_request = + new_approval("execution-orphan-approved", "call-orphan-approved"); + orphan_approved_request.worker_generation = 3; + let orphan_approved = store + .create(orphan_approved_request) + .await + .expect("orphan approved approval should persist"); + let orphan_approved = store + .decide( + &orphan_approved.record.id, + "decision-orphan-approved", + 0, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("orphan approval should be accepted before restart"); + let mut direct_approval = new_approval("execution-approved", "call-approved"); + direct_approval.worker_generation = 0; + let recoverable = store + .create(direct_approval) + .await + .expect("recoverable approval should persist"); + let recoverable = store + .decide( + &recoverable.record.id, + "decision-approved", + 0, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should be accepted"); + let recovery = store + .recover_startup() + .await + .expect("startup recovery should run"); + assert_eq!(recovery.interrupted_count, 1); + assert_eq!(recovery.approved_ids, vec![recoverable.record.id.clone()]); + assert_eq!( + store + .get_admin(&orphan_pending.record.id) + .await + .expect("orphan pending should read") + .expect("orphan pending should exist") + .record + .status, + ApprovalStatus::Canceled + ); + assert_eq!( + store + .get_admin(&orphan_approved.record.id) + .await + .expect("orphan approved should read") + .expect("orphan approved should exist") + .record + .status, + ApprovalStatus::Stale + ); + assert_eq!( + store + .get_admin(&interrupted.record.id) + .await + .expect("approval should read") + .expect("approval should exist") + .record + .status, + ApprovalStatus::Interrupted + ); + + let revoked = store + .create(new_approval("execution-revoked", "call-revoked")) + .await + .expect("revoked-token approval should persist"); + let retained = store + .create(new_approval("execution-retained", "call-retained")) + .await + .expect("executing approval should persist"); + let retained = store + .decide( + &retained.record.id, + "decision-retained", + 0, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("executing approval should be accepted"); + let retained = store + .claim_execution(&retained.record.id, 0, retained.record.revision) + .await + .expect("executing approval should start"); + assert!( + store + .revoke_owner_token("token-1") + .await + .expect("atomic revocation should run") + ); + assert!( + !store + .revoke_owner_token("token-1") + .await + .expect("repeat revocation should read") + ); + assert_eq!( + store + .get_admin(&revoked.record.id) + .await + .expect("approval should read") + .expect("approval should exist") + .record + .status, + ApprovalStatus::Canceled + ); + assert_eq!( + store + .get_admin(&retained.record.id) + .await + .expect("approval should read") + .expect("approval should exist") + .record + .status, + ApprovalStatus::Executing + ); + + let page = store + .list_admin(ApprovalListQuery { + limit: 2, + ..Default::default() + }) + .await + .expect("approval page should list"); + assert_eq!(page.items.len(), 2); + assert!(page.next_cursor.is_some()); +} + +#[tokio::test] +async fn terminal_outbox_survives_restart_until_durable_log_acknowledgement() { + let (directory, app, store, _clock) = approval_store().await; + let pending = store + .create(new_approval("execution-restart-log", "call-restart-log")) + .await + .expect("restart approval should persist"); + store + .decide( + &pending.record.id, + "direct-deny-before-restart", + pending.record.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("direct denial should commit its outbox event"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approval_log_outbox WHERE approval_id = ?", + ) + .bind(&pending.record.id) + .fetch_one(app.pool()) + .await + .expect("outbox count should read"), + 1 + ); + + drop(store); + drop(app); + + let config = AppConfig::new(directory.path().to_path_buf()); + let reopened = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match ExecutorApp::open(config.clone()).await { + Ok(app) => break app, + Err(crate::DatabaseError::AlreadyRunning(_)) => { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + Err(error) => panic!("Executor should reopen: {error}"), + } + } + }) + .await + .expect("background request logger should release the instance lock"); + wait_for_terminal_log(&reopened, &pending.record.id, "approval_denied").await; +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index 353562048..fca9dd4a0 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -457,9 +457,12 @@ pub struct DescribedTool { pub struct InvocationLookup { pub tool_id: String, pub source_id: String, + pub source_display_name: String, + pub tool_display_name: String, pub callable_path: String, pub sandbox_path: String, pub effective_mode: ToolMode, + pub mode_provenance: ModeProvenance, pub requires_approval: bool, } @@ -477,6 +480,7 @@ pub struct InvocationRevisionToken { pub struct InvocationLease { pub(crate) lookup: InvocationLookup, pub(crate) revisions: InvocationRevisionToken, + pub(crate) source_kind: SourceKind, pub(crate) binding: ToolBinding, pub(crate) input_schema: Value, pub(crate) input_validator: jsonschema::Validator, @@ -485,6 +489,32 @@ pub struct InvocationLease { pub(crate) _guard: tokio::sync::OwnedRwLockReadGuard<()>, } +pub struct InvocationPreflight { + lookup: InvocationLookup, + revisions: InvocationRevisionToken, + input_schema: Value, + input_validator: jsonschema::Validator, + _guard: tokio::sync::OwnedRwLockReadGuard<()>, +} + +impl InvocationPreflight { + pub fn lookup(&self) -> &InvocationLookup { + &self.lookup + } + + pub fn revisions(&self) -> &InvocationRevisionToken { + &self.revisions + } + + pub fn input_schema(&self) -> &Value { + &self.input_schema + } + + pub fn arguments_are_valid(&self, arguments: &Value) -> bool { + self.input_validator.is_valid(arguments) + } +} + impl InvocationLease { pub fn lookup(&self) -> &InvocationLookup { &self.lookup @@ -494,6 +524,10 @@ impl InvocationLease { &self.revisions } + pub const fn source_kind(&self) -> SourceKind { + self.source_kind + } + pub fn binding(&self) -> &ToolBinding { &self.binding } diff --git a/src/catalog/store.rs b/src/catalog/store.rs index 58b811ad4..4b352a5dc 100644 --- a/src/catalog/store.rs +++ b/src/catalog/store.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use super::{ ArtifactKind, AuditContext, BulkToolModeResult, CatalogError, CatalogSnapshot, CatalogSyncResult, CreateSource, CredentialPayload, DEFAULT_PAGE_LIMIT, DescribedTool, - DiscoveryPage, InitialCatalogSnapshot, InvocationLease, InvocationLookup, + DiscoveryPage, InitialCatalogSnapshot, InvocationLease, InvocationLookup, InvocationPreflight, InvocationRevisionToken, ListToolsFilter, MAX_PAGE_LIMIT, NewRequestLog, RequestLogPage, RequestLogRecord, RequestOutcome, RequestSurface, SourceHealth, SourceKind, SourceRecord, StagedToolBinding, StoredCredential, StoredToolBinding, ToolBinding, ToolMode, ToolPage, @@ -22,7 +22,7 @@ use super::{ use crate::{crypto::Keyring, unix_timestamp}; const CREDENTIAL_PURPOSE: &str = "source-credential-v1"; -const RESERVED_SOURCE_SLUGS: [&str; 4] = ["tools", "search", "describe", "executor"]; +const RESERVED_SOURCE_SLUGS: [&str; 5] = ["tools", "search", "describe", "sources", "executor"]; const TOOL_ERROR_TYPESCRIPT: &str = "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }"; const TOOL_HTTP_META_TYPESCRIPT: &str = "{ status: number; headers: { [k: string]: string; } }"; @@ -145,6 +145,8 @@ struct RequestLogRow { struct InvocationRow { tool_id: String, source_id: String, + source_display_name: String, + tool_display_name: String, source_kind: String, source_slug: String, local_name: String, @@ -1425,12 +1427,16 @@ impl CatalogStore { path: tool.sandbox_path, }); } + let source_display_name = self.source(&tool.source_id).await?.display_name; Ok(InvocationLookup { tool_id: tool.id, source_id: tool.source_id, + source_display_name, + tool_display_name: tool.display_name, callable_path: tool.callable_path, sandbox_path: tool.sandbox_path, effective_mode: tool.effective_mode.mode, + mode_provenance: tool.effective_mode.provenance, requires_approval: tool.effective_mode.mode == ToolMode::Ask, }) } @@ -1455,6 +1461,34 @@ impl CatalogStore { self.invocation_lease(row, guard) } + /// Resolves policy and validates the stored input schema without decrypting source credentials. + /// + /// Ask-mode callers must use this snapshot before creating an approval. The read guard keeps + /// the revision token coherent only for the duration of preflight and must not be retained while + /// an approval is pending. + pub async fn preflight_invocation( + &self, + path: &str, + ) -> Result { + let Some((source_slug, local_name)) = parse_tool_path(path) else { + return Err(CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + }); + }; + let guard = self.mutation_lock.clone().read_owned().await; + let mut transaction = self.pool.begin().await?; + let row = sqlx::query_as::<_, InvocationRow>(INVOCATION_SELECT_BY_PATH) + .bind(source_slug) + .bind(local_name) + .fetch_optional(&mut *transaction) + .await?; + transaction.commit().await?; + let row = row.ok_or_else(|| CatalogError::ToolNotFound { + path: normalize_sandbox_path(path), + })?; + Self::invocation_preflight(row, guard) + } + pub async fn revalidate_invocation( &self, token: &InvocationRevisionToken, @@ -1484,12 +1518,12 @@ impl CatalogStore { if row.present == 0 { return Err(CatalogError::ToolNotFound { path: sandbox_path }); } - let mode = effective_mode( + let effective_mode = effective_mode( ToolMode::from_str(&row.intrinsic_mode)?, parse_optional_mode(row.source_mode_override)?, parse_optional_mode(row.tool_mode_override)?, - ) - .mode; + ); + let mode = effective_mode.mode; if mode == ToolMode::Disabled { return Err(CatalogError::ToolDisabled { path: sandbox_path }); } @@ -1508,8 +1542,9 @@ impl CatalogStore { .binding_revision .ok_or(CatalogError::CorruptData("active tool binding missing"))?; let binding = ToolBinding::decode(binding_protocol, binding_version, definition_json)?; + let source_kind = SourceKind::from_str(&row.source_kind)?; if !matches!( - (&binding, SourceKind::from_str(&row.source_kind)?), + (&binding, source_kind), (ToolBinding::OpenapiV1(_), SourceKind::Openapi) ) { return Err(CatalogError::CorruptData( @@ -1541,9 +1576,12 @@ impl CatalogStore { let lookup = InvocationLookup { tool_id: row.tool_id.clone(), source_id: row.source_id.clone(), + source_display_name: row.source_display_name, + tool_display_name: row.tool_display_name, callable_path, sandbox_path, effective_mode: mode, + mode_provenance: effective_mode.provenance, requires_approval: mode == ToolMode::Ask, }; if row.input_schema_json.len() > MAX_SCHEMA_BYTES { @@ -1563,6 +1601,7 @@ impl CatalogStore { credential_revision: row.credential_revision, }, lookup, + source_kind, binding, input_schema, input_validator, @@ -1572,6 +1611,79 @@ impl CatalogStore { }) } + fn invocation_preflight( + row: InvocationRow, + guard: tokio::sync::OwnedRwLockReadGuard<()>, + ) -> Result { + let sandbox_path = format!("{}.{}", row.source_slug, row.local_name); + if row.present == 0 { + return Err(CatalogError::ToolNotFound { path: sandbox_path }); + } + let effective_mode = effective_mode( + ToolMode::from_str(&row.intrinsic_mode)?, + parse_optional_mode(row.source_mode_override)?, + parse_optional_mode(row.tool_mode_override)?, + ); + let mode = effective_mode.mode; + if mode == ToolMode::Disabled { + return Err(CatalogError::ToolDisabled { path: sandbox_path }); + } + let binding_protocol = row + .binding_protocol + .as_deref() + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let binding_version = row + .binding_version + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let definition_json = row + .definition_json + .as_deref() + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let binding_revision = row + .binding_revision + .ok_or(CatalogError::CorruptData("active tool binding missing"))?; + let binding = ToolBinding::decode(binding_protocol, binding_version, definition_json)?; + if !matches!( + (&binding, SourceKind::from_str(&row.source_kind)?), + (ToolBinding::OpenapiV1(_), SourceKind::Openapi) + ) { + return Err(CatalogError::CorruptData( + "tool binding does not match source kind", + )); + } + if row.input_schema_json.len() > MAX_SCHEMA_BYTES { + return Err(CatalogError::CorruptData("tool input schema is too large")); + } + let input_schema: Value = serde_json::from_str(&row.input_schema_json)?; + let input_validator = super::schema::compile(&input_schema) + .map_err(|()| CatalogError::CorruptData("tool input schema is invalid"))?; + Ok(InvocationPreflight { + revisions: InvocationRevisionToken { + source_id: row.source_id.clone(), + tool_id: row.tool_id.clone(), + source_revision: row.source_revision, + catalog_revision: row.catalog_revision, + tool_revision: row.tool_revision, + binding_revision, + credential_revision: row.credential_revision, + }, + lookup: InvocationLookup { + tool_id: row.tool_id, + source_id: row.source_id, + source_display_name: row.source_display_name, + tool_display_name: row.tool_display_name, + callable_path: format!("tools.{sandbox_path}"), + sandbox_path, + effective_mode: mode, + mode_provenance: effective_mode.provenance, + requires_approval: mode == ToolMode::Ask, + }, + input_schema, + input_validator, + _guard: guard, + }) + } + pub async fn record_request(&self, log: NewRequestLog) -> Result<(), CatalogError> { let duration_ms = i64::try_from(log.duration_ms).map_err(|_| { validation( @@ -2000,6 +2112,7 @@ const TOOL_SUMMARY_SELECT_BY_PATH: &str = "SELECT tools.id, tools.source_id, sou WHERE sources.slug = ? AND tools.local_name = ?"; const INVOCATION_SELECT_BY_PATH: &str = "SELECT tools.id AS tool_id, tools.source_id, \ + sources.display_name AS source_display_name, tools.display_name AS tool_display_name, \ sources.kind AS source_kind, sources.slug AS source_slug, tools.local_name, \ tools.present, tools.intrinsic_mode, \ tools.mode_override AS tool_mode_override, sources.mode_override AS source_mode_override, \ @@ -2016,6 +2129,7 @@ const INVOCATION_SELECT_BY_PATH: &str = "SELECT tools.id AS tool_id, tools.sourc WHERE sources.slug = ? AND tools.local_name = ?"; const INVOCATION_SELECT_BY_REVISION: &str = "SELECT tools.id AS tool_id, tools.source_id, \ + sources.display_name AS source_display_name, tools.display_name AS tool_display_name, \ sources.kind AS source_kind, sources.slug AS source_slug, tools.local_name, \ tools.present, tools.intrinsic_mode, \ tools.mode_override AS tool_mode_override, sources.mode_override AS source_mode_override, \ diff --git a/src/database.rs b/src/database.rs index af61f798e..d9d5c5203 100644 --- a/src/database.rs +++ b/src/database.rs @@ -15,6 +15,7 @@ use thiserror::Error; use crate::{ AppConfig, + approval::ApprovalError, crypto::{CryptoError, Keyring, generate_secret, load_or_create_master_key}, unix_timestamp, }; @@ -26,6 +27,8 @@ pub(crate) const SETUP_TOKEN_TTL_SECONDS: i64 = 15 * 60; #[derive(Debug, Error)] pub enum DatabaseError { + #[error(transparent)] + Approval(#[from] ApprovalError), #[error(transparent)] Crypto(#[from] CryptoError), #[error("could not configure the SQLite database: {0}")] @@ -202,6 +205,7 @@ async fn application_state_exists(pool: &SqlitePool) -> Result 0) \ OR EXISTS(SELECT 1 FROM instance_metadata WHERE key <> ?)", ) diff --git a/src/execution.rs b/src/execution.rs new file mode 100644 index 000000000..a186f60b2 --- /dev/null +++ b/src/execution.rs @@ -0,0 +1,440 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use thiserror::Error; +use tokio::sync::Notify; +use uuid::Uuid; + +use crate::{ + actor::ToolActor, + catalog::RequestSurface, + invocation::ToolCallService, + runtime::{ + ConsoleEntry, ExecutionCancellation, ExecutionRequest, HostToolDispatcher, + InvocationContext, InvocationToolDispatcher, RuntimeFailure, RuntimeManager, + ToolCallRecord, + }, +}; + +#[derive(Clone)] +pub struct ExecutionService { + runtime: RuntimeManager, + tool_calls: ToolCallService, + active: ActiveExecutions, +} + +#[derive(Clone, Debug)] +pub struct ExecuteCodeRequest { + pub request_id: String, + pub actor: ToolActor, + pub surface: RequestSurface, + pub code: String, + pub timeout: Duration, +} + +#[derive(Clone, Debug)] +pub struct ExecuteCodeOutput { + pub execution_id: String, + pub result: serde_json::Value, + pub emits: Vec, + pub console: Vec, + pub calls: Vec, +} + +#[derive(Debug, Error)] +pub enum ExecutionServiceError { + #[error("TypeScript source exceeded 1 MiB")] + SourceTooLarge, + #[error("execution timeout must be between 1 millisecond and 5 minutes")] + InvalidTimeout, + #[error("the TypeScript runtime is shutting down")] + ShuttingDown, + #[error("the API token is no longer active")] + OwnerRevoked, + #[error("the execution actor stopped unexpectedly")] + ActorFailed, + #[error(transparent)] + Runtime(#[from] RuntimeFailure), +} + +impl ExecutionService { + pub fn new(runtime: RuntimeManager, tool_calls: ToolCallService) -> Self { + Self { + runtime, + tool_calls, + active: ActiveExecutions::default(), + } + } + + pub async fn execute( + &self, + request: ExecuteCodeRequest, + ) -> Result { + if request.code.len() > crate::runtime::MAX_SOURCE_BYTES { + return Err(ExecutionServiceError::SourceTooLarge); + } + if request.timeout.is_zero() || request.timeout > Duration::from_secs(300) { + return Err(ExecutionServiceError::InvalidTimeout); + } + + let execution_id = Uuid::new_v4().to_string(); + let cancellation = ExecutionCancellation::default(); + let dispatcher = Arc::new(InvocationToolDispatcher::new( + self.tool_calls.clone(), + InvocationContext { + request_id: request.request_id, + actor: request.actor.clone(), + surface: request.surface, + execution_id: execution_id.clone(), + }, + )); + let stopper = ExecutionStopper { + dispatcher: dispatcher.clone(), + cancellation: cancellation.clone(), + execution_id: execution_id.clone(), + }; + if let Some(hook) = dispatcher.cancellation_hook(&execution_id) { + cancellation.register_cancel_hook(hook); + } + let active_execution = + match self + .active + .register(execution_id.clone(), request.actor.clone(), stopper.clone()) + { + Ok(active_execution) => active_execution, + Err(error) => { + dispatcher.discard_unstarted(); + return Err(error.into()); + } + }; + let runtime = self.runtime.clone(); + let runtime_execution_id = execution_id.clone(); + let runtime_cancellation = cancellation.clone(); + let execution = tokio::spawn(async move { + let _active_execution = active_execution; + runtime + .execute( + ExecutionRequest { + execution_id: runtime_execution_id, + code: request.code, + timeout: request.timeout, + }, + dispatcher, + runtime_cancellation, + ) + .await + }); + let mut cancel_on_drop = CancelOnDrop(Some(stopper)); + let output = match execution.await { + Ok(output) => { + cancel_on_drop.disarm(); + output? + } + Err(_) => return Err(ExecutionServiceError::ActorFailed), + }; + Ok(ExecuteCodeOutput { + execution_id, + result: output.result, + emits: output.emits, + console: output.console, + calls: output.tool_calls, + }) + } + + pub async fn revoke_owner(&self, owner_api_token_id: &str) { + self.active.revoke_owner(owner_api_token_id).await; + } + + pub fn cancel_all(&self) { + self.active.cancel_all(); + } + + pub async fn shutdown(&self) { + self.active.shutdown().await; + } +} + +#[derive(Clone, Default)] +struct ActiveExecutions { + state: Arc>, + changed: Arc, +} + +#[derive(Default)] +struct ActiveExecutionState { + shutting_down: bool, + revoked_owners: HashSet, + entries: HashMap, +} + +struct ActiveExecution { + actor: ToolActor, + stopper: ExecutionStopper, +} + +struct ActiveExecutionGuard { + execution_id: String, + state: Arc>, + changed: Arc, +} + +#[derive(Debug)] +enum ExecutionRegistrationError { + ShuttingDown, + OwnerRevoked, +} + +impl From for ExecutionServiceError { + fn from(error: ExecutionRegistrationError) -> Self { + match error { + ExecutionRegistrationError::ShuttingDown => Self::ShuttingDown, + ExecutionRegistrationError::OwnerRevoked => Self::OwnerRevoked, + } + } +} + +impl ActiveExecutions { + fn register( + &self, + execution_id: String, + actor: ToolActor, + stopper: ExecutionStopper, + ) -> Result { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.shutting_down { + return Err(ExecutionRegistrationError::ShuttingDown); + } + if actor + .api_token_id() + .is_some_and(|token_id| state.revoked_owners.contains(token_id)) + { + return Err(ExecutionRegistrationError::OwnerRevoked); + } + state + .entries + .insert(execution_id.clone(), ActiveExecution { actor, stopper }); + Ok(ActiveExecutionGuard { + execution_id, + state: self.state.clone(), + changed: self.changed.clone(), + }) + } + + async fn revoke_owner(&self, owner_api_token_id: &str) { + let stoppers = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.revoked_owners.insert(owner_api_token_id.to_owned()); + state + .entries + .values() + .filter(|execution| execution.actor.api_token_id() == Some(owner_api_token_id)) + .map(|execution| execution.stopper.clone()) + .collect::>() + }; + for stopper in stoppers { + stopper.stop().await; + } + } + + fn cancel_all(&self) { + let stoppers = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.shutting_down = true; + state + .entries + .values() + .map(|execution| execution.stopper.clone()) + .collect::>() + }; + for stopper in stoppers { + stopper.stop_in_background(); + } + } + + async fn shutdown(&self) { + let stoppers = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.shutting_down = true; + state + .entries + .values() + .map(|execution| execution.stopper.clone()) + .collect::>() + }; + for stopper in stoppers { + stopper.stop().await; + } + loop { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entries + .is_empty() + { + return; + } + changed.await; + } + } +} + +impl Drop for ActiveExecutionGuard { + fn drop(&mut self) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entries + .remove(&self.execution_id); + self.changed.notify_waiters(); + } +} + +#[derive(Clone)] +struct ExecutionStopper { + dispatcher: Arc, + cancellation: ExecutionCancellation, + execution_id: String, +} + +impl ExecutionStopper { + async fn stop(self) { + self.dispatcher.execution_stopping(&self.execution_id).await; + self.cancellation.cancel(); + } + + fn stop_in_background(self) { + self.cancellation.cancel(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + self.dispatcher.execution_stopping(&self.execution_id).await; + }); + } + } +} + +struct CancelOnDrop(Option); + +impl CancelOnDrop { + fn disarm(&mut self) { + self.0 = None; + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some(stopper) = self.0.take() { + stopper.stop_in_background(); + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, + }; + + use super::ActiveExecutions; + use crate::{actor::ToolActor, runtime::ExecutionCancellation}; + + #[tokio::test] + async fn shutdown_waits_for_detached_execution_cleanup() { + let executions = ActiveExecutions::default(); + let cancellation = ExecutionCancellation::default(); + struct TestDispatcher; + #[async_trait::async_trait] + impl crate::runtime::HostToolDispatcher for TestDispatcher { + async fn dispatch( + &self, + _: crate::runtime::ToolCall, + _: ExecutionCancellation, + ) -> crate::runtime::ToolResult { + unreachable!("test dispatcher is never called") + } + } + let stopper = super::ExecutionStopper { + dispatcher: std::sync::Arc::new(TestDispatcher), + cancellation: cancellation.clone(), + execution_id: "detached-execution".to_owned(), + }; + let guard = executions + .register( + "detached-execution".to_owned(), + ToolActor::api_token("owner-token", None), + stopper, + ) + .expect("execution should register"); + let shutdown_executions = executions.clone(); + let mut shutdown = tokio::spawn(async move { + shutdown_executions.shutdown().await; + }); + tokio::time::timeout(Duration::from_secs(1), cancellation.cancelled()) + .await + .expect("shutdown should cancel the execution"); + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut shutdown) + .await + .is_err(), + "shutdown must wait for the detached actor guard" + ); + drop(guard); + tokio::time::timeout(Duration::from_secs(1), shutdown) + .await + .expect("shutdown should finish after cleanup") + .expect("shutdown task should not panic"); + } + + #[test] + fn background_stop_runs_cancellation_hooks_synchronously() { + let cancellation = ExecutionCancellation::default(); + let stopped = Arc::new(AtomicBool::new(false)); + let hook_stopped = stopped.clone(); + cancellation.register_cancel_hook(Arc::new(move || { + hook_stopped.store(true, Ordering::Release); + })); + struct TestDispatcher; + #[async_trait::async_trait] + impl crate::runtime::HostToolDispatcher for TestDispatcher { + async fn dispatch( + &self, + _: crate::runtime::ToolCall, + _: ExecutionCancellation, + ) -> crate::runtime::ToolResult { + unreachable!("test dispatcher is never called") + } + } + let stopper = super::ExecutionStopper { + dispatcher: Arc::new(TestDispatcher), + cancellation: cancellation.clone(), + execution_id: "synchronous-stop".to_owned(), + }; + + stopper.stop_in_background(); + + assert!(stopped.load(Ordering::Acquire)); + assert!(cancellation.is_cancelled()); + } +} diff --git a/src/invocation/idempotency.rs b/src/invocation/idempotency.rs new file mode 100644 index 000000000..2a5b8230c --- /dev/null +++ b/src/invocation/idempotency.rs @@ -0,0 +1,1555 @@ +use std::{ + collections::BTreeMap, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{FromRow, SqlitePool}; +use subtle::ConstantTimeEq; +use thiserror::Error; +use uuid::Uuid; + +use crate::crypto::{CryptoError, Keyring}; + +pub(crate) const IDEMPOTENCY_KEY_MAX_BYTES: usize = 255; +pub(crate) const IDEMPOTENCY_TTL_SECONDS: i64 = 24 * 60 * 60; +const MAX_ARGUMENT_BYTES: usize = 8 * 1024 * 1024; +const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; +const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; +const MAX_RESPONSE_HEADERS: usize = 64; +const MAX_RESPONSE_CIPHERTEXT_BYTES: i64 = 12 * 1024 * 1024; +const MAX_TOTAL_RESPONSE_CIPHERTEXT_BYTES: i64 = 64 * 1024 * 1024; +const MAX_RECORDS: i64 = 10_000; +const MAX_RECORDS_PER_OWNER: i64 = 1_000; + +#[derive(Clone, Copy)] +struct IdempotencyLimits { + records: i64, + records_per_owner: i64, + response_ciphertext_bytes: i64, +} + +impl Default for IdempotencyLimits { + fn default() -> Self { + Self { + records: MAX_RECORDS, + records_per_owner: MAX_RECORDS_PER_OWNER, + response_ciphertext_bytes: MAX_TOTAL_RESPONSE_CIPHERTEXT_BYTES, + } + } +} + +trait IdempotencyClock: Send + Sync + 'static { + fn now(&self) -> i64; +} + +#[derive(Clone, Copy, Default)] +struct SystemClock; + +impl IdempotencyClock for SystemClock { + fn now(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time must be after the Unix epoch") + .as_secs() as i64 + } +} + +#[derive(Clone)] +pub(crate) struct GatewayIdempotencyStore { + pool: SqlitePool, + keyring: Keyring, + clock: Arc, + limits: IdempotencyLimits, +} + +#[derive(Clone, Copy)] +pub(crate) struct IdempotencyOwner<'a> { + pub owner_api_token_id: &'a str, +} + +pub(crate) struct IdempotencyRequest<'a> { + pub owner: IdempotencyOwner<'a>, + pub key: &'a str, + pub route: &'a str, + pub callable_path: &'a str, + pub arguments: &'a Value, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum IdempotencyState { + Reserved, + Executing, + Completed, + Indeterminate, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum IdempotencyResponseKind { + Tool, + Approval, +} + +impl IdempotencyResponseKind { + const fn as_str(self) -> &'static str { + match self { + Self::Tool => "tool", + Self::Approval => "approval", + } + } +} + +impl IdempotencyState { + const fn as_str(self) -> &'static str { + match self { + Self::Reserved => "reserved", + Self::Executing => "executing", + Self::Completed => "completed", + Self::Indeterminate => "indeterminate", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct IdempotencyRecord { + pub id: String, + pub owner_api_token_id: String, + pub state: IdempotencyState, + pub approval_id: Option, + pub response_kind: Option, + pub created_at: i64, + pub updated_at: i64, + pub completed_at: Option, + pub expires_at: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct IdempotencyResponse { + pub status: u16, + pub headers: BTreeMap, + pub body: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum IdempotencyClaim { + Fresh(IdempotencyRecord), + InProgress(IdempotencyRecord), + Replay { + record: IdempotencyRecord, + response: IdempotencyResponse, + }, + Indeterminate(IdempotencyRecord), + Mismatch, +} + +pub(crate) enum CorrelatedApproval { + Live(String), + Retired, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct IdempotencyRecovery { + pub indeterminate_executions: u64, + pub reserved: Vec, +} + +#[derive(Debug, Error)] +pub(crate) enum IdempotencyError { + #[error("gateway idempotency storage failed: {0}")] + Database(#[from] sqlx::Error), + #[error("gateway idempotency protection failed: {0}")] + Crypto(#[from] CryptoError), + #[error("gateway idempotency JSON failed: {0}")] + Json(#[from] serde_json::Error), + #[error("the Idempotency-Key must contain between 1 and 255 visible ASCII bytes")] + InvalidKey, + #[error("gateway idempotency metadata is invalid")] + InvalidMetadata, + #[error("the gateway idempotency payload exceeds the allowed size")] + PayloadTooLarge, + #[error("gateway idempotency capacity has been reached")] + Capacity, + #[error("the gateway idempotency record was not found")] + NotFound, + #[error("the gateway idempotency record cannot transition from {0}")] + InvalidTransition(&'static str), + #[error("stored gateway idempotency data is invalid")] + CorruptData, +} + +impl GatewayIdempotencyStore { + pub(crate) fn new(pool: SqlitePool, keyring: Keyring) -> Self { + Self { + pool, + keyring, + clock: Arc::new(SystemClock), + limits: IdempotencyLimits::default(), + } + } + + #[cfg(test)] + fn with_clock(pool: SqlitePool, keyring: Keyring, clock: Arc) -> Self { + Self { + pool, + keyring, + clock, + limits: IdempotencyLimits::default(), + } + } + + #[cfg(test)] + fn with_limits(mut self, limits: IdempotencyLimits) -> Self { + self.limits = limits; + self + } + + pub(crate) async fn claim( + &self, + request: IdempotencyRequest<'_>, + ) -> Result { + validate_identifier(request.owner.owner_api_token_id, 128)?; + validate_key(request.key)?; + validate_identifier(request.route, 200)?; + let callable_path = canonical_callable_path(request.callable_path)?; + let canonical_arguments = canonical_json(request.arguments)?; + if canonical_arguments.len() > MAX_ARGUMENT_BYTES { + return Err(IdempotencyError::PayloadTooLarge); + } + let key_digest = self.key_digest(request.owner, request.key); + let request_digest = self.request_digest( + request.owner, + request.route, + &callable_path, + &canonical_arguments, + ); + let id = Uuid::new_v4().to_string(); + let observed_now = self.clock.now().max(0); + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + delete_expired(&mut transaction, now).await?; + let inserted = sqlx::query( + "INSERT INTO gateway_invocation_idempotency ( \ + id, owner_api_token_id, key_digest, request_digest, state, created_at, updated_at \ + ) SELECT ?, ?, ?, ?, 'reserved', ?, ? \ + WHERE (SELECT COUNT(*) FROM gateway_invocation_idempotency) < ? \ + AND (SELECT COUNT(*) FROM gateway_invocation_idempotency \ + WHERE owner_api_token_id = ?) < ? \ + ON CONFLICT(owner_api_token_id, key_digest) DO NOTHING", + ) + .bind(&id) + .bind(request.owner.owner_api_token_id) + .bind(key_digest.to_vec()) + .bind(request_digest.to_vec()) + .bind(now) + .bind(now) + .bind(self.limits.records) + .bind(request.owner.owner_api_token_id) + .bind(self.limits.records_per_owner) + .execute(&mut *transaction) + .await?; + if inserted.rows_affected() == 1 { + transaction.commit().await?; + return Ok(IdempotencyClaim::Fresh(IdempotencyRecord { + id, + owner_api_token_id: request.owner.owner_api_token_id.to_owned(), + state: IdempotencyState::Reserved, + approval_id: None, + response_kind: None, + created_at: now, + updated_at: now, + completed_at: None, + expires_at: None, + })); + } + + let existing = fetch_by_key( + &mut transaction, + request.owner.owner_api_token_id, + &key_digest, + ) + .await?; + let Some(existing) = existing else { + return Err(IdempotencyError::Capacity); + }; + if !bool::from( + existing + .request_digest + .as_slice() + .ct_eq(request_digest.as_slice()), + ) { + transaction.commit().await?; + return Ok(IdempotencyClaim::Mismatch); + } + transaction.commit().await?; + let record = existing.record()?; + match record.state { + IdempotencyState::Reserved | IdempotencyState::Executing => { + Ok(IdempotencyClaim::InProgress(record)) + } + IdempotencyState::Indeterminate => Ok(IdempotencyClaim::Indeterminate(record)), + IdempotencyState::Completed => { + if record.response_kind == Some(IdempotencyResponseKind::Approval) + && record.approval_id.is_none() + { + return Ok(IdempotencyClaim::Indeterminate(record)); + } + let ciphertext = existing + .response_ciphertext + .as_deref() + .ok_or(IdempotencyError::CorruptData)?; + let response = self.decrypt_response(&record.id, ciphertext)?; + Ok(IdempotencyClaim::Replay { record, response }) + } + } + } + + pub(crate) async fn lookup( + &self, + request: IdempotencyRequest<'_>, + ) -> Result, IdempotencyError> { + validate_identifier(request.owner.owner_api_token_id, 128)?; + validate_key(request.key)?; + validate_identifier(request.route, 200)?; + let callable_path = canonical_callable_path(request.callable_path)?; + let canonical_arguments = canonical_json(request.arguments)?; + if canonical_arguments.len() > MAX_ARGUMENT_BYTES { + return Err(IdempotencyError::PayloadTooLarge); + } + let key_digest = self.key_digest(request.owner, request.key); + let request_digest = self.request_digest( + request.owner, + request.route, + &callable_path, + &canonical_arguments, + ); + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, self.clock.now().max(0)).await?; + delete_expired(&mut transaction, now).await?; + let existing = fetch_by_key( + &mut transaction, + request.owner.owner_api_token_id, + &key_digest, + ) + .await?; + transaction.commit().await?; + let Some(existing) = existing else { + return Ok(None); + }; + if !bool::from( + existing + .request_digest + .as_slice() + .ct_eq(request_digest.as_slice()), + ) { + return Ok(Some(IdempotencyClaim::Mismatch)); + } + let record = existing.record()?; + Ok(Some(match record.state { + IdempotencyState::Reserved | IdempotencyState::Executing => { + IdempotencyClaim::InProgress(record) + } + IdempotencyState::Indeterminate => IdempotencyClaim::Indeterminate(record), + IdempotencyState::Completed => { + if record.response_kind == Some(IdempotencyResponseKind::Approval) + && record.approval_id.is_none() + { + return Ok(Some(IdempotencyClaim::Indeterminate(record))); + } + let ciphertext = existing + .response_ciphertext + .as_deref() + .ok_or(IdempotencyError::CorruptData)?; + let response = self.decrypt_response(&record.id, ciphertext)?; + IdempotencyClaim::Replay { record, response } + } + })) + } + + pub(crate) async fn mark_executing(&self, id: &str) -> Result<(), IdempotencyError> { + validate_identifier(id, 128)?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, self.clock.now().max(0)).await?; + let result = sqlx::query( + "UPDATE gateway_invocation_idempotency \ + SET state = 'executing', updated_at = ? \ + WHERE id = ? AND state = 'reserved'", + ) + .bind(now) + .bind(id) + .execute(&mut *transaction) + .await?; + if result.rows_affected() != 1 { + let error = transition_error_in(&mut transaction, id).await?; + return Err(error); + } + transaction.commit().await?; + Ok(()) + } + + pub(crate) async fn release_reserved(&self, id: &str) -> Result { + validate_identifier(id, 128)?; + let result = sqlx::query( + "DELETE FROM gateway_invocation_idempotency AS idempotency \ + WHERE idempotency.id = ? AND idempotency.state = 'reserved' \ + AND NOT EXISTS ( \ + SELECT 1 FROM approval_correlations \ + WHERE approval_correlations.execution_id = 'gateway-idempotency:' || idempotency.id \ + AND approval_correlations.call_id = 'gateway' \ + AND approval_correlations.actor_kind = 'api_token' \ + AND approval_correlations.actor_id = idempotency.owner_api_token_id \ + )", + ) + .bind(id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + + pub(crate) async fn abandon(&self, id: &str) -> Result<(), IdempotencyError> { + validate_identifier(id, 128)?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, self.clock.now().max(0)).await?; + let expires_at = now + .checked_add(IDEMPOTENCY_TTL_SECONDS) + .ok_or(IdempotencyError::InvalidMetadata)?; + sqlx::query( + "DELETE FROM gateway_invocation_idempotency AS idempotency \ + WHERE idempotency.id = ? AND idempotency.state = 'reserved' \ + AND NOT EXISTS ( \ + SELECT 1 FROM approval_correlations AS correlation \ + WHERE correlation.execution_id = 'gateway-idempotency:' || idempotency.id \ + AND correlation.call_id = 'gateway' \ + AND correlation.actor_kind = 'api_token' \ + AND correlation.actor_id = idempotency.owner_api_token_id \ + )", + ) + .bind(id) + .execute(&mut *transaction) + .await?; + mark_executing_indeterminate_in(&mut transaction, id, now, expires_at).await?; + sqlx::query( + "UPDATE gateway_invocation_idempotency AS idempotency \ + SET state = 'indeterminate', updated_at = ?, completed_at = ?, expires_at = ? \ + WHERE idempotency.id = ? AND idempotency.state = 'reserved' \ + AND EXISTS ( \ + SELECT 1 FROM approval_correlations AS correlation \ + LEFT JOIN approvals AS approval ON approval.id = correlation.approval_id \ + WHERE correlation.execution_id = 'gateway-idempotency:' || idempotency.id \ + AND correlation.call_id = 'gateway' \ + AND correlation.actor_kind = 'api_token' \ + AND correlation.actor_id = idempotency.owner_api_token_id \ + AND approval.id IS NULL \ + )", + ) + .bind(now) + .bind(now) + .bind(expires_at) + .bind(id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) + } + + pub(crate) async fn mark_indeterminate( + &self, + id: &str, + ) -> Result { + validate_identifier(id, 128)?; + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, self.clock.now().max(0)).await?; + let expires_at = now + .checked_add(IDEMPOTENCY_TTL_SECONDS) + .ok_or(IdempotencyError::InvalidMetadata)?; + let result = mark_indeterminate_in(&mut transaction, id, now, expires_at).await?; + if result == 0 { + let error = transition_error_in(&mut transaction, id).await?; + return Err(error); + } + transaction.commit().await?; + self.get(id).await?.ok_or(IdempotencyError::NotFound) + } + + pub(crate) async fn complete( + &self, + id: &str, + kind: IdempotencyResponseKind, + response: &IdempotencyResponse, + approval_id: Option<&str>, + ) -> Result { + validate_identifier(id, 128)?; + if let Err(error) = validate_response(response) { + self.mark_indeterminate_if_executing(id).await?; + return Err(error); + } + let encoded = encode_response(response)?; + let ciphertext = match self + .keyring + .encrypt("gateway-idempotency-response-v1", id, &encoded) + { + Ok(ciphertext) => ciphertext, + Err(error) => { + self.mark_indeterminate_if_executing(id).await?; + return Err(error.into()); + } + }; + let ciphertext_len = + i64::try_from(ciphertext.len()).map_err(|_| IdempotencyError::PayloadTooLarge)?; + if ciphertext_len > MAX_RESPONSE_CIPHERTEXT_BYTES { + self.mark_indeterminate_if_executing(id).await?; + return Err(IdempotencyError::PayloadTooLarge); + } + let observed_now = self.clock.now().max(0); + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + let expires_at = now + .checked_add(IDEMPOTENCY_TTL_SECONDS) + .ok_or(IdempotencyError::InvalidMetadata)?; + let result = sqlx::query( + "UPDATE gateway_invocation_idempotency \ + SET state = 'completed', approval_id = ?, response_kind = ?, response_ciphertext = ?, \ + updated_at = ?, completed_at = ?, expires_at = ? \ + WHERE id = ? AND state IN ('reserved', 'executing') \ + AND ? <= ? - ( \ + SELECT COALESCE(SUM(length(response_ciphertext)), 0) \ + FROM gateway_invocation_idempotency WHERE id <> ? \ + )", + ) + .bind(approval_id) + .bind(kind.as_str()) + .bind(ciphertext) + .bind(now) + .bind(now) + .bind(expires_at) + .bind(id) + .bind(ciphertext_len) + .bind(self.limits.response_ciphertext_bytes) + .bind(id) + .execute(&mut *transaction) + .await?; + if result.rows_affected() != 1 { + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(id) + .fetch_optional(&mut *transaction) + .await?; + let error = match state.as_deref() { + Some("executing") => { + mark_executing_indeterminate_in(&mut transaction, id, now, expires_at).await?; + transaction.commit().await?; + IdempotencyError::Capacity + } + Some("reserved") => IdempotencyError::Capacity, + Some(state) => IdempotencyError::InvalidTransition(state_name(state)?), + None => IdempotencyError::NotFound, + }; + return Err(error); + } + transaction.commit().await?; + self.get(id).await?.ok_or(IdempotencyError::NotFound) + } + + async fn mark_indeterminate_if_executing(&self, id: &str) -> Result<(), IdempotencyError> { + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, self.clock.now().max(0)).await?; + let expires_at = now + .checked_add(IDEMPOTENCY_TTL_SECONDS) + .ok_or(IdempotencyError::InvalidMetadata)?; + mark_executing_indeterminate_in(&mut transaction, id, now, expires_at).await?; + transaction.commit().await?; + Ok(()) + } + + pub(crate) async fn recover_startup(&self) -> Result { + let observed_now = self.clock.now().max(0); + let mut transaction = self.pool.begin().await?; + let now = effective_now_in(&mut transaction, observed_now).await?; + let expires_at = now + .checked_add(IDEMPOTENCY_TTL_SECONDS) + .ok_or(IdempotencyError::InvalidMetadata)?; + delete_expired(&mut transaction, now).await?; + let indeterminate = sqlx::query( + "UPDATE gateway_invocation_idempotency \ + SET state = 'indeterminate', updated_at = ?, completed_at = ?, expires_at = ? \ + WHERE state = 'executing'", + ) + .bind(now) + .bind(now) + .bind(expires_at) + .execute(&mut *transaction) + .await? + .rows_affected(); + let reserved = sqlx::query_as::<_, IdempotencyRow>( + "SELECT id, owner_api_token_id, request_digest, state, approval_id, response_kind, \ + response_ciphertext, created_at, updated_at, completed_at, expires_at \ + FROM gateway_invocation_idempotency WHERE state = 'reserved' ORDER BY sequence", + ) + .fetch_all(&mut *transaction) + .await? + .into_iter() + .map(|row| row.record()) + .collect::, _>>()?; + transaction.commit().await?; + Ok(IdempotencyRecovery { + indeterminate_executions: indeterminate, + reserved, + }) + } + + pub(crate) async fn correlated_approval_id( + &self, + record: &IdempotencyRecord, + ) -> Result, IdempotencyError> { + let row = sqlx::query_as::<_, (String, Option)>( + "SELECT correlation.approval_id, approval.id \ + FROM approval_correlations AS correlation \ + LEFT JOIN approvals AS approval ON approval.id = correlation.approval_id \ + WHERE correlation.execution_id = ? AND correlation.call_id = 'gateway' \ + AND correlation.actor_kind = 'api_token' AND correlation.actor_id = ?", + ) + .bind(idempotency_execution_id(&record.id)) + .bind(&record.owner_api_token_id) + .fetch_optional(&self.pool) + .await + .map_err(IdempotencyError::Database)?; + Ok(row.map(|(approval_id, live_id)| { + if live_id.is_some() { + CorrelatedApproval::Live(approval_id) + } else { + CorrelatedApproval::Retired + } + })) + } + + async fn get(&self, id: &str) -> Result, IdempotencyError> { + let row = sqlx::query_as::<_, IdempotencyRow>( + "SELECT id, owner_api_token_id, request_digest, state, approval_id, response_kind, \ + response_ciphertext, created_at, updated_at, completed_at, expires_at \ + FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + row.map(|row| row.record()).transpose() + } + + fn key_digest(&self, owner: IdempotencyOwner<'_>, key: &str) -> [u8; 32] { + let mut input = Vec::with_capacity(owner.owner_api_token_id.len() + key.len() + 16); + append_field(&mut input, owner.owner_api_token_id.as_bytes()); + append_field(&mut input, key.as_bytes()); + self.keyring.digest("gateway-idempotency-key-v1", &input) + } + + fn request_digest( + &self, + owner: IdempotencyOwner<'_>, + route: &str, + callable_path: &str, + canonical_arguments: &[u8], + ) -> [u8; 32] { + let arguments_digest = self + .keyring + .digest("gateway-idempotency-arguments-v1", canonical_arguments); + let mut input = Vec::with_capacity( + owner.owner_api_token_id.len() + route.len() + callable_path.len() + 96, + ); + input.extend_from_slice(b"executor-gateway-idempotency-request-v1"); + append_field(&mut input, owner.owner_api_token_id.as_bytes()); + append_field(&mut input, route.as_bytes()); + append_field(&mut input, callable_path.as_bytes()); + append_field(&mut input, &arguments_digest); + self.keyring + .digest("gateway-idempotency-request-v1", &input) + } + + fn decrypt_response( + &self, + id: &str, + ciphertext: &[u8], + ) -> Result { + let encoded = self + .keyring + .decrypt("gateway-idempotency-response-v1", id, ciphertext)?; + decode_response(&encoded) + } +} + +#[derive(FromRow)] +struct IdempotencyRow { + id: String, + owner_api_token_id: String, + request_digest: Vec, + state: String, + approval_id: Option, + response_kind: Option, + response_ciphertext: Option>, + created_at: i64, + updated_at: i64, + completed_at: Option, + expires_at: Option, +} + +impl IdempotencyRow { + fn record(&self) -> Result { + Ok(IdempotencyRecord { + id: self.id.clone(), + owner_api_token_id: self.owner_api_token_id.clone(), + state: parse_state(&self.state)?, + approval_id: self.approval_id.clone(), + response_kind: self + .response_kind + .as_deref() + .map(parse_response_kind) + .transpose()?, + created_at: self.created_at, + updated_at: self.updated_at, + completed_at: self.completed_at, + expires_at: self.expires_at, + }) + } +} + +async fn fetch_by_key( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + owner_api_token_id: &str, + key_digest: &[u8], +) -> Result, sqlx::Error> { + sqlx::query_as::<_, IdempotencyRow>( + "SELECT id, owner_api_token_id, request_digest, state, approval_id, response_kind, \ + response_ciphertext, created_at, updated_at, completed_at, expires_at \ + FROM gateway_invocation_idempotency \ + WHERE owner_api_token_id = ? AND key_digest = ?", + ) + .bind(owner_api_token_id) + .bind(key_digest) + .fetch_optional(&mut **transaction) + .await +} + +async fn transition_error_in( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, +) -> Result { + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(id) + .fetch_optional(&mut **transaction) + .await?; + Ok(match state { + Some(state) => IdempotencyError::InvalidTransition(state_name(&state)?), + None => IdempotencyError::NotFound, + }) +} + +async fn mark_indeterminate_in( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + now: i64, + expires_at: i64, +) -> Result { + sqlx::query( + "UPDATE gateway_invocation_idempotency \ + SET state = 'indeterminate', updated_at = ?, completed_at = ?, expires_at = ? \ + WHERE id = ? AND state IN ('reserved', 'executing')", + ) + .bind(now) + .bind(now) + .bind(expires_at) + .bind(id) + .execute(&mut **transaction) + .await + .map(|result| result.rows_affected()) +} + +async fn mark_executing_indeterminate_in( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + now: i64, + expires_at: i64, +) -> Result { + sqlx::query( + "UPDATE gateway_invocation_idempotency \ + SET state = 'indeterminate', updated_at = ?, completed_at = ?, expires_at = ? \ + WHERE id = ? AND state = 'executing'", + ) + .bind(now) + .bind(now) + .bind(expires_at) + .bind(id) + .execute(&mut **transaction) + .await + .map(|result| result.rows_affected()) +} + +async fn delete_expired( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + now: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + "DELETE FROM gateway_invocation_idempotency \ + WHERE state IN ('completed', 'indeterminate') AND expires_at <= ?", + ) + .bind(now) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn effective_now_in( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + observed_now: i64, +) -> Result { + sqlx::query( + "UPDATE gateway_idempotency_clock \ + SET effective_now = MAX(effective_now, ?) WHERE id = 1", + ) + .bind(observed_now) + .execute(&mut **transaction) + .await?; + sqlx::query_scalar("SELECT effective_now FROM gateway_idempotency_clock WHERE id = 1") + .fetch_one(&mut **transaction) + .await +} + +pub(crate) fn validate_key(key: &str) -> Result<(), IdempotencyError> { + let bytes = key.as_bytes(); + if bytes.is_empty() + || bytes.len() > IDEMPOTENCY_KEY_MAX_BYTES + || bytes.iter().any(|byte| !(0x21..=0x7e).contains(byte)) + { + return Err(IdempotencyError::InvalidKey); + } + Ok(()) +} + +pub(crate) fn idempotency_execution_id(record_id: &str) -> String { + format!("gateway-idempotency:{record_id}") +} + +fn validate_identifier(value: &str, max: usize) -> Result<(), IdempotencyError> { + if value.is_empty() || value.len() > max || value.contains('\0') { + return Err(IdempotencyError::InvalidMetadata); + } + Ok(()) +} + +pub(crate) fn canonical_callable_path(path: &str) -> Result { + let path = path.strip_prefix("tools.").unwrap_or(path); + let mut segments = path.split('.'); + let source = segments.next().unwrap_or_default(); + let tool = segments.next().unwrap_or_default(); + if source.is_empty() + || tool.is_empty() + || segments.next().is_some() + || source.len() + tool.len() + 7 > 512 + || source.contains('\0') + || tool.contains('\0') + { + return Err(IdempotencyError::InvalidMetadata); + } + Ok(format!("tools.{source}.{tool}")) +} + +fn canonical_json(value: &Value) -> Result, IdempotencyError> { + fn sort(value: &Value) -> Value { + match value { + Value::Object(object) => { + let sorted = object + .iter() + .map(|(key, value)| (key.clone(), sort(value))) + .collect::>(); + Value::Object(sorted.into_iter().collect()) + } + Value::Array(values) => Value::Array(values.iter().map(sort).collect()), + value => value.clone(), + } + } + serde_json::to_vec(&sort(value)).map_err(IdempotencyError::Json) +} + +fn append_field(target: &mut Vec, value: &[u8]) { + target.extend_from_slice(&(value.len() as u64).to_be_bytes()); + target.extend_from_slice(value); +} + +fn validate_response(response: &IdempotencyResponse) -> Result<(), IdempotencyError> { + if !(100..=599).contains(&response.status) + || response.body.len() > MAX_RESPONSE_BODY_BYTES + || response.headers.len() > MAX_RESPONSE_HEADERS + { + return Err(IdempotencyError::PayloadTooLarge); + } + let header_bytes = response + .headers + .iter() + .try_fold(0_usize, |total, (name, value)| { + if name.is_empty() + || name.contains(['\0', '\r', '\n']) + || value.contains(['\0', '\r', '\n']) + { + return None; + } + total.checked_add(name.len())?.checked_add(value.len()) + }); + if header_bytes.is_none_or(|bytes| bytes > MAX_RESPONSE_HEADER_BYTES) { + return Err(IdempotencyError::PayloadTooLarge); + } + Ok(()) +} + +#[derive(Deserialize, Serialize)] +struct StoredResponse { + status: u16, + headers: BTreeMap, + body_base64: String, +} + +fn encode_response(response: &IdempotencyResponse) -> Result, IdempotencyError> { + serde_json::to_vec(&StoredResponse { + status: response.status, + headers: response.headers.clone(), + body_base64: STANDARD.encode(&response.body), + }) + .map_err(IdempotencyError::Json) +} + +fn decode_response(encoded: &[u8]) -> Result { + let stored: StoredResponse = serde_json::from_slice(encoded)?; + validate_response(&IdempotencyResponse { + status: stored.status, + headers: stored.headers.clone(), + body: Vec::new(), + })?; + let body = STANDARD + .decode(stored.body_base64) + .map_err(|_| IdempotencyError::CorruptData)?; + let response = IdempotencyResponse { + status: stored.status, + headers: stored.headers, + body, + }; + validate_response(&response)?; + Ok(response) +} + +fn parse_state(state: &str) -> Result { + match state { + "reserved" => Ok(IdempotencyState::Reserved), + "executing" => Ok(IdempotencyState::Executing), + "completed" => Ok(IdempotencyState::Completed), + "indeterminate" => Ok(IdempotencyState::Indeterminate), + _ => Err(IdempotencyError::CorruptData), + } +} + +fn parse_response_kind(value: &str) -> Result { + match value { + "tool" => Ok(IdempotencyResponseKind::Tool), + "approval" => Ok(IdempotencyResponseKind::Approval), + _ => Err(IdempotencyError::CorruptData), + } +} + +fn state_name(state: &str) -> Result<&'static str, IdempotencyError> { + Ok(parse_state(state)?.as_str()) +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicI64, Ordering}, + }; + + use serde_json::json; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + use super::*; + + static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + + struct ManualClock(AtomicI64); + + impl ManualClock { + fn new(now: i64) -> Self { + Self(AtomicI64::new(now)) + } + + fn set(&self, now: i64) { + self.0.store(now, Ordering::SeqCst); + } + } + + impl IdempotencyClock for ManualClock { + fn now(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } + } + + async fn store() -> (tempfile::TempDir, GatewayIdempotencyStore, Arc) { + let directory = tempfile::tempdir().expect("temporary directory"); + let database_path = directory.path().join("idempotency.db"); + let options = SqliteConnectOptions::new() + .filename(&database_path) + .create_if_missing(true) + .foreign_keys(true) + .busy_timeout(std::time::Duration::from_secs(5)); + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(options) + .await + .expect("database pool"); + MIGRATOR.run(&pool).await.expect("migrations"); + for owner in ["owner-a", "owner-b"] { + sqlx::query( + "INSERT INTO api_tokens (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES (?, ?, ?, 'tok_', 'tail', 1)", + ) + .bind(owner) + .bind(owner) + .bind(owner.as_bytes()) + .execute(&pool) + .await + .expect("owner token"); + } + let clock = Arc::new(ManualClock::new(1_000)); + let store = GatewayIdempotencyStore::with_clock( + pool, + Keyring::from_master_key([7; 32]).expect("keyring"), + clock.clone(), + ); + (directory, store, clock) + } + + fn request<'a>( + owner: &'a str, + key: &'a str, + path: &'a str, + arguments: &'a Value, + ) -> IdempotencyRequest<'a> { + IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: owner, + }, + key, + route: "POST /api/v1/gateway/tools/invoke", + callable_path: path, + arguments, + } + } + + #[tokio::test] + async fn matching_requests_replay_and_mismatches_do_not_reuse() { + let (_directory, store, _clock) = store().await; + let first_arguments = json!({"alpha": 1, "nested": {"z": 2, "a": 3}}); + let reordered_arguments = json!({"nested": {"a": 3, "z": 2}, "alpha": 1}); + let fresh = store + .claim(request( + "owner-a", + "retry-key", + "source.tool", + &first_arguments, + )) + .await + .expect("fresh claim"); + let IdempotencyClaim::Fresh(record) = fresh else { + panic!("first claim must be fresh"); + }; + let response = IdempotencyResponse { + status: 202, + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: br#"{"approval":{"id":"approval-1"}}"#.to_vec(), + }; + store + .complete(&record.id, IdempotencyResponseKind::Tool, &response, None) + .await + .expect("completion"); + + let replay = store + .claim(request( + "owner-a", + "retry-key", + "tools.source.tool", + &reordered_arguments, + )) + .await + .expect("replay"); + assert!(matches!( + replay, + IdempotencyClaim::Replay { response: replayed, .. } if replayed == response + )); + assert!(matches!( + store + .claim(request( + "owner-a", + "retry-key", + "source.other", + &first_arguments, + )) + .await + .expect("path mismatch"), + IdempotencyClaim::Mismatch + )); + assert!(matches!( + store + .claim(request( + "owner-a", + "retry-key", + "source.tool", + &json!({"alpha": 2}), + )) + .await + .expect("argument mismatch"), + IdempotencyClaim::Mismatch + )); + assert!(matches!( + store + .claim(request( + "owner-b", + "retry-key", + "source.tool", + &first_arguments, + )) + .await + .expect("other owner"), + IdempotencyClaim::Fresh(_) + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_claims_elect_exactly_one_winner() { + let (_directory, store, _clock) = store().await; + let barrier = Arc::new(tokio::sync::Barrier::new(33)); + let mut tasks = Vec::new(); + for _ in 0..32 { + let store = store.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + let arguments = json!({"value": 1}); + barrier.wait().await; + store + .claim(request( + "owner-a", + "concurrent-key", + "source.tool", + &arguments, + )) + .await + })); + } + barrier.wait().await; + let mut fresh = 0; + let mut in_progress = 0; + for task in tasks { + match task.await.expect("claim task").expect("claim") { + IdempotencyClaim::Fresh(_) => fresh += 1, + IdempotencyClaim::InProgress(_) => in_progress += 1, + other => panic!("unexpected claim: {other:?}"), + } + } + assert_eq!(fresh, 1); + assert_eq!(in_progress, 31); + } + + #[tokio::test] + async fn startup_never_replays_an_uncertain_execution() { + let (_directory, store, _clock) = store().await; + let arguments = json!({"value": 1}); + let fresh = store + .claim(request( + "owner-a", + "uncertain-key", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = fresh else { + panic!("fresh claim expected"); + }; + store + .mark_executing(&record.id) + .await + .expect("execution boundary"); + let recovery = store.recover_startup().await.expect("startup recovery"); + assert_eq!(recovery.indeterminate_executions, 1); + assert!(matches!( + store + .claim(request( + "owner-a", + "uncertain-key", + "source.tool", + &arguments, + )) + .await + .expect("post-restart claim"), + IdempotencyClaim::Indeterminate(_) + )); + } + + #[tokio::test] + async fn abandoned_reservations_can_be_released_without_reusing_an_active_claim() { + let (_directory, store, _clock) = store().await; + let arguments = json!({"value": 1}); + let fresh = store + .claim(request( + "owner-a", + "cancelled-key", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = fresh else { + panic!("fresh claim expected"); + }; + assert!( + store + .release_reserved(&record.id) + .await + .expect("reservation release") + ); + assert!( + !store + .release_reserved(&record.id) + .await + .expect("second release") + ); + assert!(matches!( + store + .claim(request( + "owner-a", + "cancelled-key", + "source.tool", + &arguments, + )) + .await + .expect("replacement claim"), + IdempotencyClaim::Fresh(_) + )); + } + + #[tokio::test] + async fn capacity_is_fail_closed_before_and_after_the_execution_boundary() { + let (_directory, store, _clock) = store().await; + let store = store.with_limits(IdempotencyLimits { + records: 2, + records_per_owner: 1, + response_ciphertext_bytes: 0, + }); + let arguments = json!({"value": 1}); + let fresh = store + .claim(request( + "owner-a", + "capacity-key", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = fresh else { + panic!("fresh claim expected"); + }; + assert!(matches!( + store + .claim(request("owner-a", "second-key", "source.tool", &arguments,)) + .await, + Err(IdempotencyError::Capacity) + )); + store + .mark_executing(&record.id) + .await + .expect("execution boundary"); + assert!(matches!( + store + .complete( + &record.id, + IdempotencyResponseKind::Tool, + &IdempotencyResponse { + status: 200, + headers: BTreeMap::new(), + body: b"observed response".to_vec(), + }, + None, + ) + .await, + Err(IdempotencyError::Capacity) + )); + assert!(matches!( + store + .claim(request( + "owner-a", + "capacity-key", + "source.tool", + &arguments, + )) + .await + .expect("uncertain replay"), + IdempotencyClaim::Indeterminate(_) + )); + } + + #[tokio::test] + async fn approval_retention_can_clear_a_terminal_reference() { + let (_directory, store, _clock) = store().await; + sqlx::query( + "INSERT INTO approvals ( \ + id, execution_id, call_id, worker_generation, actor_kind, actor_id, \ + actor_api_token_id, surface, source_id, tool_id, callable_path_snapshot, \ + mode_provenance, source_revision, catalog_revision, tool_revision, \ + binding_revision, arguments_digest, arguments_ciphertext, \ + redacted_arguments_ciphertext, input_schema_ciphertext, \ + invocation_snapshot_ciphertext, status, created_at, updated_at, expires_at \ + ) VALUES ( \ + 'approval-1', 'execution-1', 'call-1', 0, 'api_token', 'owner-a', \ + 'owner-a', 'gateway', 'source-1', 'tool-1', 'tools.source.tool', \ + 'intrinsic', 0, 0, 0, 0, zeroblob(32), zeroblob(42), zeroblob(42), \ + zeroblob(42), zeroblob(42), 'pending', 1, 1, 601 \ + )", + ) + .execute(&store.pool) + .await + .expect("approval fixture"); + let arguments = json!({}); + let fresh = store + .claim(request( + "owner-a", + "approval-key", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = fresh else { + panic!("fresh claim expected"); + }; + store + .complete( + &record.id, + IdempotencyResponseKind::Approval, + &IdempotencyResponse { + status: 202, + headers: BTreeMap::new(), + body: b"approval required".to_vec(), + }, + Some("approval-1"), + ) + .await + .expect("completion"); + sqlx::query("DELETE FROM approvals WHERE id = 'approval-1'") + .execute(&store.pool) + .await + .expect("approval retention deletion"); + let approval_id = sqlx::query_scalar::<_, Option>( + "SELECT approval_id FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(record.id) + .fetch_one(&store.pool) + .await + .expect("idempotency record"); + assert_eq!(approval_id, None); + } + + #[tokio::test] + async fn retired_approval_correlation_never_reopens_a_reserved_key() { + let (_directory, store, _clock) = store().await; + let arguments = json!({}); + let claim = store + .claim(request( + "owner-a", + "retired-approval-key", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = claim else { + panic!("claim must be fresh"); + }; + sqlx::query( + "INSERT INTO approvals ( \ + id, execution_id, call_id, worker_generation, actor_kind, actor_id, \ + actor_api_token_id, surface, source_id, tool_id, callable_path_snapshot, \ + mode_provenance, source_revision, catalog_revision, tool_revision, \ + binding_revision, arguments_digest, arguments_ciphertext, \ + redacted_arguments_ciphertext, input_schema_ciphertext, \ + invocation_snapshot_ciphertext, status, created_at, updated_at, expires_at \ + ) VALUES ( \ + 'retired-approval', ?, 'gateway', 0, 'api_token', 'owner-a', \ + 'owner-a', 'gateway', 'source-1', 'tool-1', 'tools.source.tool', \ + 'intrinsic', 0, 0, 0, 0, zeroblob(32), zeroblob(42), zeroblob(42), \ + zeroblob(42), zeroblob(42), 'pending', 1, 1, 601 \ + )", + ) + .bind(idempotency_execution_id(&record.id)) + .execute(&store.pool) + .await + .expect("approval fixture"); + sqlx::query("DELETE FROM approvals WHERE id = 'retired-approval'") + .execute(&store.pool) + .await + .expect("approval retention deletion"); + assert!( + !store + .release_reserved(&record.id) + .await + .expect("retired correlation blocks release") + ); + let recovery = store.recover_startup().await.expect("startup recovery"); + assert_eq!(recovery.reserved, vec![record.clone()]); + assert!(matches!( + store + .correlated_approval_id(&record) + .await + .expect("correlation lookup"), + Some(CorrelatedApproval::Retired) + )); + store + .mark_indeterminate(&record.id) + .await + .expect("retired reservation terminalizes"); + assert!(matches!( + store + .claim(request( + "owner-a", + "retired-approval-key", + "source.tool", + &arguments, + )) + .await + .expect("post-restart retry"), + IdempotencyClaim::Indeterminate(_) + )); + } + + #[tokio::test] + async fn terminal_keys_expire_after_twenty_four_hours() { + let (_directory, store, clock) = store().await; + let arguments = json!({}); + let fresh = store + .claim(request( + "owner-a", + "expiring-key", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = fresh else { + panic!("fresh claim expected"); + }; + store + .complete( + &record.id, + IdempotencyResponseKind::Tool, + &IdempotencyResponse { + status: 200, + headers: BTreeMap::new(), + body: b"ok".to_vec(), + }, + None, + ) + .await + .expect("completion"); + clock.set(1_000 + IDEMPOTENCY_TTL_SECONDS - 1); + assert!(matches!( + store + .claim(request( + "owner-a", + "expiring-key", + "source.tool", + &arguments, + )) + .await + .expect("retained replay"), + IdempotencyClaim::Replay { .. } + )); + clock.set(1_000 + IDEMPOTENCY_TTL_SECONDS); + assert!(matches!( + store + .claim(request( + "owner-a", + "expiring-key", + "source.tool", + &arguments, + )) + .await + .expect("reused expired key"), + IdempotencyClaim::Fresh(_) + )); + } + + #[tokio::test] + async fn secrets_and_replay_payloads_are_not_stored_as_plaintext() { + let (_directory, store, _clock) = store().await; + let arguments = json!({"secret": "argument-plaintext-marker"}); + let fresh = store + .claim(request( + "owner-a", + "raw-idempotency-key-marker", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = fresh else { + panic!("fresh claim expected"); + }; + store + .complete( + &record.id, + IdempotencyResponseKind::Tool, + &IdempotencyResponse { + status: 200, + headers: BTreeMap::new(), + body: b"response-plaintext-marker".to_vec(), + }, + None, + ) + .await + .expect("completion"); + let stored = sqlx::query_as::<_, (Vec, Vec, Vec)>( + "SELECT key_digest, request_digest, response_ciphertext \ + FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(record.id) + .fetch_one(&store.pool) + .await + .expect("stored row"); + let mut bytes = Vec::new(); + bytes.extend(stored.0); + bytes.extend(stored.1); + bytes.extend(stored.2); + let storage = String::from_utf8_lossy(&bytes); + assert!(!storage.contains("raw-idempotency-key-marker")); + assert!(!storage.contains("argument-plaintext-marker")); + assert!(!storage.contains("response-plaintext-marker")); + } + + #[test] + fn key_validation_is_bounded_and_visible_ascii_only() { + assert!(validate_key("a").is_ok()); + assert!(validate_key(&"x".repeat(255)).is_ok()); + assert!(matches!( + validate_key(""), + Err(IdempotencyError::InvalidKey) + )); + assert!(matches!( + validate_key(&"x".repeat(256)), + Err(IdempotencyError::InvalidKey) + )); + assert!(matches!( + validate_key("contains space"), + Err(IdempotencyError::InvalidKey) + )); + assert!(matches!( + validate_key("non-ascii-é"), + Err(IdempotencyError::InvalidKey) + )); + } +} diff --git a/src/invocation/mod.rs b/src/invocation/mod.rs new file mode 100644 index 000000000..62be01253 --- /dev/null +++ b/src/invocation/mod.rs @@ -0,0 +1,2599 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + future::Future, + sync::atomic::{AtomicBool, Ordering}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sqlx::SqlitePool; +use thiserror::Error; + +use crate::{ + actor::ToolActor, + approval::{ + ApprovalAdminDetail, ApprovalDecision, ApprovalDeliveryIdentity, ApprovalError, + ApprovalOwnerDetail, ApprovalRecord, ApprovalStatus, ApprovalStore, ExecutionOutcome, + NewApproval, + }, + catalog::{ + CatalogError, CatalogStore, InvocationLease, ListToolsFilter, NewRequestLog, + RequestOutcome, RequestSurface, ToolMode, + }, + crypto::Keyring, + outbound::OutboundError, + protocols::{ + PreparedProtocolInvocation, ProtocolError, ProtocolInvocationError, ProtocolRegistry, + }, + request_logs::RequestLogSink, + tasks::TaskTracker, +}; +use tokio::sync::{Notify, OwnedSemaphorePermit, RwLock, Semaphore, oneshot}; + +mod idempotency; + +use idempotency::{ + CorrelatedApproval, GatewayIdempotencyStore, IdempotencyClaim, IdempotencyError, + IdempotencyOwner, IdempotencyRecord, IdempotencyRequest, IdempotencyResponse, + IdempotencyResponseKind, idempotency_execution_id, +}; + +const APPROVAL_EXECUTION_CONCURRENCY: usize = 16; +const GATEWAY_INVOKE_ROUTE: &str = "POST /api/v1/gateway/tools/invoke"; + +pub const MAX_ARGUMENT_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_RESULT_BYTES: usize = 8 * 1024 * 1024; + +pub(crate) fn gateway_idempotency_key_is_valid(key: &str) -> bool { + idempotency::validate_key(key).is_ok() +} + +#[derive(Clone, Debug)] +pub struct ToolCall { + pub request_id: String, + pub actor: ToolActor, + pub surface: RequestSurface, + pub execution_id: String, + pub call_id: String, + /// Zero identifies a directly resumable call. Sandbox generations start at one and are never + /// replayed after their worker continuation has been lost. + pub worker_generation: u64, + pub path: String, + pub arguments: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolResult { + pub ok: bool, + pub data: Option, + pub error: Option, + pub http: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PublicToolError { + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ToolHttpMetadata { + pub status: u16, + pub headers: BTreeMap, + pub truncated: bool, +} + +#[derive(Debug, Error)] +pub enum ToolCallError { + #[error(transparent)] + Approval(#[from] ApprovalError), + #[error(transparent)] + Catalog(#[from] CatalogError), + #[error("{message}")] + Adapter { code: &'static str, message: String }, + #[error("tool arguments exceed the allowed size")] + ArgumentsTooLarge, + #[error("tool arguments do not match the input schema")] + InvalidArguments, + #[error("the invocation changed before it could execute")] + Stale, + #[error(transparent)] + Outbound(#[from] OutboundError), + #[error(transparent)] + Protocol(#[from] ProtocolError), + #[error("the tool result exceeds the allowed size")] + ResultTooLarge, +} + +#[derive(Clone)] +pub struct ToolCallService { + catalog: CatalogStore, + protocols: ProtocolRegistry, + approvals: ApprovalStore, + approval_queries: ApprovalQueries, + idempotency: GatewayIdempotencyStore, + request_logs: RequestLogSink, + approval_notifications: ApprovalNotificationRegistry, + global_approval_notify: Arc, + expiry_slot: Arc, + next_expiry_scan: Arc>, + approval_log_flush: Arc, + approval_log_requested: Arc, + discovery_slots: Arc, + execution_slots: Arc, + in_flight_approvals: Arc>>, + deferred_execution_cancellations: Arc>>, + execution_stopping_flags: Arc>>>, + background_tasks: TaskTracker, +} + +#[derive(Clone)] +pub struct ApprovalQueries { + approvals: ApprovalStore, +} + +impl ApprovalQueries { + pub async fn get_admin( + &self, + approval_id: &str, + ) -> Result, ApprovalError> { + self.approvals.get_admin(approval_id).await + } + + pub async fn get_for_token( + &self, + approval_id: &str, + actor_api_token_id: &str, + ) -> Result, ApprovalError> { + self.approvals + .get_for_token(approval_id, actor_api_token_id) + .await + } + + pub async fn list_admin( + &self, + query: crate::approval::ApprovalListQuery, + ) -> Result { + self.approvals.list_admin(query).await + } +} + +#[derive(Debug)] +pub enum ToolCallSubmission { + Completed(ToolResult), + ApprovalRequired(ApprovalRequired), +} + +pub struct ApprovalRequired { + record: Box, + delivery: Option, +} + +impl std::fmt::Debug for ApprovalRequired { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ApprovalRequired") + .field("record", &self.record) + .finish_non_exhaustive() + } +} + +impl std::ops::Deref for ApprovalRequired { + type Target = ApprovalRecord; + + fn deref(&self) -> &Self::Target { + &self.record + } +} + +struct ApprovalDeliveryTicket { + identity: Option, + approvals: ApprovalStore, + notifications: ApprovalNotificationRegistry, + tasks: TaskTracker, +} + +impl ApprovalDeliveryTicket { + async fn release(&mut self) { + let Some(identity) = self.identity.take() else { + return; + }; + if let Err(error) = self.approvals.release_delivery_pin(&identity).await { + tracing::warn!( + approval_id = identity.approval_id, + error = %error, + "approval delivery pin release will retry" + ); + self.identity = Some(identity); + return; + } + notify_registry(&self.notifications, &identity.approval_id); + } +} + +impl Drop for ApprovalDeliveryTicket { + fn drop(&mut self) { + let Some(identity) = self.identity.take() else { + return; + }; + let approvals = self.approvals.clone(); + let notifications = self.notifications.clone(); + self.tasks.spawn(async move { + let mut delay = Duration::from_millis(25); + loop { + match approvals.release_delivery_pin(&identity).await { + Ok(_) => { + notify_registry(¬ifications, &identity.approval_id); + return; + } + Err(error) => { + tracing::warn!( + approval_id = identity.approval_id, + error = %error, + "approval delivery pin cleanup will retry" + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(5)); + } + } + } + }); + } +} + +#[derive(Clone, Debug)] +pub(crate) struct GatewayInvokeResponse { + pub response: IdempotencyResponse, + pub replayed: bool, +} + +#[derive(Debug, Error)] +pub(crate) enum GatewayInvokeError { + #[error(transparent)] + ToolCall(#[from] ToolCallError), + #[error("the Idempotency-Key is invalid")] + InvalidKey, + #[error("gateway idempotency capacity has been reached")] + Capacity, + #[error("gateway idempotency failed: {0}")] + Idempotency(String), + #[error("the Idempotency-Key was already used for a different invocation")] + KeyMismatch, + #[error("the idempotent invocation is still in progress")] + InProgress, + #[error("the idempotent invocation outcome is unknown")] + OutcomeUnknown, +} + +impl From for GatewayInvokeError { + fn from(error: IdempotencyError) -> Self { + match error { + IdempotencyError::InvalidKey => Self::InvalidKey, + IdempotencyError::Capacity => Self::Capacity, + error => Self::Idempotency(error.to_string()), + } + } +} + +#[derive(Debug, Error)] +pub enum ApprovalWaitError { + #[error(transparent)] + Approval(#[from] ApprovalError), + #[error("approval wait was canceled")] + Canceled, +} + +#[derive(Debug, Error)] +pub enum ToolDiscoveryError { + #[error("tool search is busy")] + Busy, + #[error(transparent)] + Catalog(#[from] CatalogError), + #[error("tool discovery task was interrupted")] + Interrupted, +} + +type ApprovalNotificationRegistry = Arc>>; + +struct ApprovalNotificationEntry { + notify: Arc, + waiters: usize, +} + +struct ApprovalWaitRegistration { + approval_id: String, + notify: Arc, + registry: ApprovalNotificationRegistry, +} + +struct IdempotencyReservationGuard { + record: IdempotencyRecord, + store: GatewayIdempotencyStore, + approvals: ApprovalStore, + tasks: TaskTracker, + armed: bool, +} + +impl IdempotencyReservationGuard { + fn new( + record: IdempotencyRecord, + store: GatewayIdempotencyStore, + approvals: ApprovalStore, + tasks: TaskTracker, + ) -> Self { + Self { + record, + store, + approvals, + tasks, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for IdempotencyReservationGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let id = self.record.id.clone(); + let record = self.record.clone(); + let store = self.store.clone(); + let approvals = self.approvals.clone(); + self.tasks.spawn(async move { + let mut delay = Duration::from_millis(25); + loop { + match settle_abandoned_reservation(&store, &approvals, &record).await { + Ok(()) => break, + Err(error) => { + tracing::warn!(idempotency_id = id, error = %error, "idempotency reservation cleanup will retry"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(5)); + } + } + } + }); + } +} + +async fn settle_abandoned_reservation( + store: &GatewayIdempotencyStore, + approvals: &ApprovalStore, + record: &IdempotencyRecord, +) -> Result<(), String> { + match store + .correlated_approval_id(record) + .await + .map_err(|error| error.to_string())? + { + Some(CorrelatedApproval::Live(approval_id)) => { + let approval = approvals + .get_admin(&approval_id) + .await + .map_err(|error| error.to_string())?; + let Some(approval) = approval else { + return store + .abandon(&record.id) + .await + .map_err(|error| error.to_string()); + }; + let response = match approval_required_response(&approval.record) { + Ok(response) => response, + Err(_) => { + terminalize_idempotency(store, &record.id).await; + return Ok(()); + } + }; + match store + .complete( + &record.id, + IdempotencyResponseKind::Approval, + &response, + Some(&approval_id), + ) + .await + { + Ok(_) | Err(IdempotencyError::InvalidTransition("completed" | "indeterminate")) => { + Ok(()) + } + Err(_) => { + terminalize_idempotency(store, &record.id).await; + Ok(()) + } + } + } + Some(CorrelatedApproval::Retired) | None => store + .abandon(&record.id) + .await + .map_err(|error| error.to_string()), + } +} + +struct IdempotencyExecutionGuard { + id: String, + store: GatewayIdempotencyStore, + tasks: TaskTracker, + armed: bool, +} + +impl IdempotencyExecutionGuard { + fn new(id: String, store: GatewayIdempotencyStore, tasks: TaskTracker) -> Self { + Self { + id, + store, + tasks, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for IdempotencyExecutionGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let id = self.id.clone(); + let store = self.store.clone(); + self.tasks.spawn(async move { + let mut delay = Duration::from_millis(25); + loop { + match store.mark_indeterminate(&id).await { + Ok(_) + | Err(IdempotencyError::InvalidTransition( + "completed" | "indeterminate", + )) => break, + Err(error) => { + tracing::error!(idempotency_id = id, error = %error, "uncertain invocation terminalization will retry"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(5)); + } + } + } + }); + } +} + +struct IdempotencyAskCompletionGuard { + id: String, + approval_id: String, + response: IdempotencyResponse, + store: GatewayIdempotencyStore, + tasks: TaskTracker, + armed: bool, +} + +impl IdempotencyAskCompletionGuard { + fn new( + id: String, + approval_id: String, + response: IdempotencyResponse, + store: GatewayIdempotencyStore, + tasks: TaskTracker, + ) -> Self { + Self { + id, + approval_id, + response, + store, + tasks, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for IdempotencyAskCompletionGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let id = self.id.clone(); + let approval_id = self.approval_id.clone(); + let response = self.response.clone(); + let store = self.store.clone(); + self.tasks.spawn(async move { + match store + .complete( + &id, + IdempotencyResponseKind::Approval, + &response, + Some(&approval_id), + ) + .await + { + Ok(_) | Err(IdempotencyError::InvalidTransition("completed")) => {} + Err(_) => terminalize_idempotency(&store, &id).await, + } + }); + } +} + +async fn terminalize_idempotency(store: &GatewayIdempotencyStore, id: &str) { + let mut delay = Duration::from_millis(25); + loop { + match store.mark_indeterminate(id).await { + Ok(_) + | Err(IdempotencyError::InvalidTransition("completed" | "indeterminate")) + | Err(IdempotencyError::NotFound) => return, + Err(error) => { + tracing::error!(idempotency_id = id, error = %error, "idempotency terminalization will retry"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(5)); + } + } + } +} + +impl Drop for ApprovalWaitRegistration { + fn drop(&mut self) { + let mut registry = self + .registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let remove = registry.get_mut(&self.approval_id).is_some_and(|entry| { + if !Arc::ptr_eq(&entry.notify, &self.notify) { + return false; + } + entry.waiters = entry.waiters.saturating_sub(1); + entry.waiters == 0 + }); + if remove { + registry.remove(&self.approval_id); + } + } +} + +impl ToolCallService { + pub(crate) fn new( + catalog: CatalogStore, + protocols: ProtocolRegistry, + pool: SqlitePool, + keyring: Keyring, + request_logs: RequestLogSink, + ) -> Self { + let idempotency = GatewayIdempotencyStore::new(pool.clone(), keyring.clone()); + let approvals = ApprovalStore::system(pool, keyring); + Self { + catalog, + protocols, + approval_queries: ApprovalQueries { + approvals: approvals.clone(), + }, + approvals, + idempotency, + request_logs, + approval_notifications: Arc::new(Mutex::new(HashMap::new())), + global_approval_notify: Arc::new(Notify::new()), + expiry_slot: Arc::new(Semaphore::new(1)), + next_expiry_scan: Arc::new(Mutex::new(Instant::now())), + approval_log_flush: Arc::new(Semaphore::new(1)), + approval_log_requested: Arc::new(AtomicBool::new(false)), + discovery_slots: Arc::new(Semaphore::new(1)), + execution_slots: Arc::new(Semaphore::new(APPROVAL_EXECUTION_CONCURRENCY)), + in_flight_approvals: Arc::new(Mutex::new(HashSet::new())), + deferred_execution_cancellations: Arc::new(RwLock::new(HashSet::new())), + execution_stopping_flags: Arc::new(Mutex::new(HashMap::new())), + background_tasks: TaskTracker::default(), + } + } + + pub fn approvals(&self) -> &ApprovalQueries { + &self.approval_queries + } + + pub(crate) fn discovery_slots(&self) -> Arc { + self.discovery_slots.clone() + } + + pub(crate) fn record_rejected_request( + &self, + request_id: &str, + actor_api_token_id: &str, + surface: RequestSurface, + path: &str, + error_code: &str, + ) { + self.record(NewRequestLog { + request_id: request_id.to_owned(), + actor_api_token_id: Some(actor_api_token_id.to_owned()), + surface, + source_id: None, + tool_id: None, + path_snapshot: Some(path.to_owned()), + outcome: RequestOutcome::Failed, + error_code: Some(error_code.to_owned()), + duration_ms: 0, + approval_id: None, + created_at: crate::unix_timestamp(), + }); + } + + pub(crate) async fn shutdown(&self) { + self.background_tasks.shutdown().await; + self.settle_idempotency_shutdown().await; + let mut delay = Duration::from_millis(25); + loop { + match self.approvals.clear_delivery_pins().await { + Ok(_) => break, + Err(error) => { + tracing::error!(error = %error, "shutdown approval delivery cleanup will retry"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(5)); + } + } + } + self.global_approval_notify.notify_waiters(); + self.request_logs.shutdown().await; + } + + async fn settle_idempotency_shutdown(&self) { + let mut delay = Duration::from_millis(25); + loop { + let result = async { + let recovery = self + .idempotency + .recover_startup() + .await + .map_err(|error| error.to_string())?; + for reservation in recovery.reserved { + settle_abandoned_reservation(&self.idempotency, &self.approvals, &reservation) + .await?; + } + Ok::<_, String>(()) + } + .await; + match result { + Ok(()) => return, + Err(error) => { + tracing::error!(error, "shutdown idempotency settlement will retry"); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(5)); + } + } + } + } + + pub(crate) fn abort_background_tasks(&self) { + self.background_tasks.abort_all(); + self.request_logs.abort(); + } + + pub(crate) async fn recover_startup(&self) -> Result<(), ApprovalError> { + let idempotency_recovery = self + .idempotency + .recover_startup() + .await + .map_err(idempotency_startup_error)?; + for reservation in idempotency_recovery.reserved { + let approval_id = self + .idempotency + .correlated_approval_id(&reservation) + .await + .map_err(idempotency_startup_error)?; + let approval = match approval_id { + Some(CorrelatedApproval::Live(approval_id)) => { + match self.approvals.get_admin(&approval_id).await? { + Some(approval) => approval, + None => { + terminalize_idempotency(&self.idempotency, &reservation.id).await; + continue; + } + } + } + Some(CorrelatedApproval::Retired) => { + self.idempotency + .mark_indeterminate(&reservation.id) + .await + .map_err(idempotency_startup_error)?; + continue; + } + None => { + self.idempotency + .release_reserved(&reservation.id) + .await + .map_err(idempotency_startup_error)?; + continue; + } + }; + let response = match approval_required_response(&approval.record) { + Ok(response) => response, + Err(_) => { + terminalize_idempotency(&self.idempotency, &reservation.id).await; + continue; + } + }; + if self + .idempotency + .complete( + &reservation.id, + IdempotencyResponseKind::Approval, + &response, + Some(&approval.record.id), + ) + .await + .is_err() + { + terminalize_idempotency(&self.idempotency, &reservation.id).await; + } + } + let recovery = self.approvals.recover_startup().await?; + self.schedule_approval_log_flush_if_pending().await?; + for approval_id in &recovery.approved_ids { + if self + .approvals + .get_admin(approval_id) + .await? + .is_some_and(|detail| detail.record.worker_generation == 0) + { + self.spawn_approved(approval_id.clone()); + } + } + Ok(()) + } + + pub async fn submit(&self, call: ToolCall) -> Result { + let started = Instant::now(); + if let Some(actor_api_token_id) = call.actor.api_token_id() + && !token_is_active(self.catalog.pool(), actor_api_token_id).await? + { + self.record_attempt( + &call, + None, + None, + RequestOutcome::Denied, + Some("token_revoked"), + None, + started, + ); + return Err(ApprovalError::OwnerTokenInactive.into()); + } + let encoded_arguments = + serde_json::to_vec(&call.arguments).map_err(|_| ToolCallError::InvalidArguments)?; + if encoded_arguments.len() > MAX_ARGUMENT_BYTES { + self.record_attempt( + &call, + None, + None, + RequestOutcome::Failed, + Some("arguments_too_large"), + None, + started, + ); + return Err(ToolCallError::ArgumentsTooLarge); + } + let preflight = match self.catalog.preflight_invocation(&call.path).await { + Ok(preflight) => preflight, + Err(error) => { + let outcome = if matches!(error, CatalogError::ToolDisabled { .. }) { + RequestOutcome::Denied + } else { + RequestOutcome::Failed + }; + let error = ToolCallError::Catalog(error); + let code = error_code(&error); + self.record_attempt(&call, None, None, outcome, Some(code), None, started); + return Err(error); + } + }; + if !preflight.arguments_are_valid(&call.arguments) { + self.record_attempt( + &call, + Some(preflight.lookup().source_id.clone()), + Some(preflight.lookup().tool_id.clone()), + RequestOutcome::Failed, + Some("invalid_tool_arguments"), + None, + started, + ); + return Err(ToolCallError::InvalidArguments); + } + let token = preflight.revisions().clone(); + let lookup = preflight.lookup().clone(); + if lookup.requires_approval { + let input_schema = preflight.input_schema().clone(); + drop(preflight); + let invocation_snapshot = json!({ + "version": 1, + "requestId": call.request_id.clone(), + "actorKind": call.actor.kind(), + "actorId": call.actor.id(), + "actorApiTokenId": call.actor.api_token_id(), + "surface": call.surface, + "executionId": call.execution_id.clone(), + "callId": call.call_id.clone(), + "path": lookup.callable_path.clone(), + "sourceId": lookup.source_id.clone(), + "toolId": lookup.tool_id.clone(), + }); + let service = self.clone(); + let (completed, result) = oneshot::channel(); + let spawned = self.background_tasks.spawn(async move { + let pending = service + .approvals + .create(NewApproval { + execution_id: call.execution_id.clone(), + call_id: call.call_id.clone(), + worker_generation: call.worker_generation, + actor: call.actor.clone(), + surface: call.surface, + callable_path_snapshot: lookup.callable_path.clone(), + source_display_name_snapshot: Some(lookup.source_display_name.clone()), + tool_display_name_snapshot: Some(lookup.tool_display_name.clone()), + mode_provenance: lookup.mode_provenance, + revisions: token, + arguments: call.arguments.clone(), + input_schema, + output_schema: None, + invocation_snapshot, + }) + .await; + let submission = pending.map(|pending| { + let approval_id = pending.record.id.clone(); + service.record_attempt( + &call, + Some(lookup.source_id), + Some(lookup.tool_id), + RequestOutcome::PendingApproval, + Some("approval_required"), + Some(approval_id), + started, + ); + let delivery = pending.delivery_pin.then(|| ApprovalDeliveryTicket { + identity: Some(ApprovalDeliveryIdentity { + approval_id: pending.record.id.clone(), + actor: call.actor.clone(), + execution_id: call.execution_id.clone(), + call_id: call.call_id.clone(), + }), + approvals: service.approvals.clone(), + notifications: service.approval_notifications.clone(), + tasks: service.background_tasks.clone(), + }); + ToolCallSubmission::ApprovalRequired(ApprovalRequired { + record: Box::new(pending.record), + delivery, + }) + }); + let _ = completed.send(submission.map_err(ToolCallError::from)); + }); + if !spawned { + return Err(ToolCallError::Adapter { + code: "approval_persistence_unavailable", + message: "approval persistence is shutting down".to_owned(), + }); + } + return result.await.unwrap_or_else(|_| { + Err(ToolCallError::Adapter { + code: "approval_persistence_interrupted", + message: "approval persistence was interrupted".to_owned(), + }) + }); + } + drop(preflight); + let lease = self + .catalog + .revalidate_invocation(&token) + .await? + .ok_or(ToolCallError::Stale)?; + let result = execute_with_lease(&self.protocols, lease, &call.arguments).await; + match result { + Ok(result) => { + self.record_attempt( + &call, + Some(lookup.source_id), + Some(lookup.tool_id), + if result.ok { + RequestOutcome::Succeeded + } else { + RequestOutcome::Failed + }, + (!result.ok).then_some("upstream_http_error"), + None, + started, + ); + Ok(ToolCallSubmission::Completed(result)) + } + Err(error) => { + self.record_attempt( + &call, + Some(lookup.source_id), + Some(lookup.tool_id), + RequestOutcome::Failed, + Some(error_code(&error)), + None, + started, + ); + Err(error) + } + } + } + + pub(crate) async fn submit_gateway_idempotent( + &self, + mut call: ToolCall, + key: &str, + ) -> Result { + let started = Instant::now(); + let actor_api_token_id = call + .actor + .api_token_id() + .ok_or(IdempotencyError::InvalidMetadata)? + .to_owned(); + if call.surface != RequestSurface::Gateway { + return Err(IdempotencyError::InvalidMetadata.into()); + } + if !token_is_active(self.catalog.pool(), &actor_api_token_id) + .await + .map_err(ToolCallError::Approval)? + { + self.record_attempt( + &call, + None, + None, + RequestOutcome::Denied, + Some("token_revoked"), + None, + started, + ); + return Err(ToolCallError::Approval(ApprovalError::OwnerTokenInactive).into()); + } + let encoded_arguments = + serde_json::to_vec(&call.arguments).map_err(|_| ToolCallError::InvalidArguments)?; + if encoded_arguments.len() > MAX_ARGUMENT_BYTES { + self.record_attempt( + &call, + None, + None, + RequestOutcome::Failed, + Some("arguments_too_large"), + None, + started, + ); + return Err(ToolCallError::ArgumentsTooLarge.into()); + } + let request_path = idempotency::canonical_callable_path(&call.path)?; + if let Some(existing) = self + .idempotency + .lookup(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: &actor_api_token_id, + }, + key, + route: GATEWAY_INVOKE_ROUTE, + callable_path: &request_path, + arguments: &call.arguments, + }) + .await? + { + match existing { + IdempotencyClaim::Replay { response, .. } => { + return Ok(GatewayInvokeResponse { + response, + replayed: true, + }); + } + IdempotencyClaim::Mismatch => return Err(GatewayInvokeError::KeyMismatch), + IdempotencyClaim::Indeterminate(_) => { + return Err(GatewayInvokeError::OutcomeUnknown); + } + IdempotencyClaim::InProgress(record) => { + if record.state == idempotency::IdempotencyState::Reserved + && let Some(response) = self + .reconcile_idempotent_approval( + &record, + &actor_api_token_id, + key, + &request_path, + &call.arguments, + ) + .await? + { + return Ok(response); + } + return Err(GatewayInvokeError::InProgress); + } + IdempotencyClaim::Fresh(_) => { + return Err(GatewayInvokeError::Idempotency( + "lookup returned a fresh idempotency claim".to_owned(), + )); + } + } + } + let preflight = match self.catalog.preflight_invocation(&call.path).await { + Ok(preflight) => preflight, + Err(error) => { + let outcome = if matches!(error, CatalogError::ToolDisabled { .. }) { + RequestOutcome::Denied + } else { + RequestOutcome::Failed + }; + let error = ToolCallError::Catalog(error); + let code = error_code(&error); + self.record_attempt(&call, None, None, outcome, Some(code), None, started); + return Err(error.into()); + } + }; + if !preflight.arguments_are_valid(&call.arguments) { + self.record_attempt( + &call, + Some(preflight.lookup().source_id.clone()), + Some(preflight.lookup().tool_id.clone()), + RequestOutcome::Failed, + Some("invalid_tool_arguments"), + None, + started, + ); + return Err(ToolCallError::InvalidArguments.into()); + } + let token = preflight.revisions().clone(); + let lookup = preflight.lookup().clone(); + let claim = self + .idempotency + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: &actor_api_token_id, + }, + key, + route: GATEWAY_INVOKE_ROUTE, + callable_path: &lookup.callable_path, + arguments: &call.arguments, + }) + .await?; + let record = match claim { + IdempotencyClaim::Fresh(record) => record, + IdempotencyClaim::Replay { response, .. } => { + return Ok(GatewayInvokeResponse { + response, + replayed: true, + }); + } + IdempotencyClaim::Mismatch => return Err(GatewayInvokeError::KeyMismatch), + IdempotencyClaim::Indeterminate(_) => { + return Err(GatewayInvokeError::OutcomeUnknown); + } + IdempotencyClaim::InProgress(record) => { + if record.state == idempotency::IdempotencyState::Reserved + && let Some(response) = self + .reconcile_idempotent_approval( + &record, + &actor_api_token_id, + key, + &lookup.callable_path, + &call.arguments, + ) + .await? + { + return Ok(response); + } + return Err(GatewayInvokeError::InProgress); + } + }; + let mut reservation = IdempotencyReservationGuard::new( + record, + self.idempotency.clone(), + self.approvals.clone(), + self.background_tasks.clone(), + ); + call.execution_id = idempotency_execution_id(&reservation.record.id); + call.call_id = "gateway".to_owned(); + + if lookup.requires_approval { + let input_schema = preflight.input_schema().clone(); + let invocation_snapshot = json!({ + "version": 1, + "requestId": call.request_id.clone(), + "actorKind": call.actor.kind(), + "actorId": call.actor.id(), + "actorApiTokenId": call.actor.api_token_id(), + "surface": call.surface, + "executionId": call.execution_id.clone(), + "callId": call.call_id.clone(), + "path": lookup.callable_path.clone(), + "sourceId": lookup.source_id.clone(), + "toolId": lookup.tool_id.clone(), + }); + let pending = match self + .approvals + .create(NewApproval { + execution_id: call.execution_id.clone(), + call_id: call.call_id.clone(), + worker_generation: call.worker_generation, + actor: call.actor.clone(), + surface: call.surface, + callable_path_snapshot: lookup.callable_path.clone(), + source_display_name_snapshot: Some(lookup.source_display_name.clone()), + tool_display_name_snapshot: Some(lookup.tool_display_name.clone()), + mode_provenance: lookup.mode_provenance, + revisions: token, + arguments: call.arguments.clone(), + input_schema, + output_schema: None, + invocation_snapshot, + }) + .await + { + Ok(pending) => pending, + Err(error) => { + let existing = self + .idempotency + .correlated_approval_id(&reservation.record) + .await?; + if existing.is_none() { + self.idempotency + .release_reserved(&reservation.record.id) + .await?; + } + return Err(ToolCallError::Approval(error).into()); + } + }; + let approval_id = pending.record.id.clone(); + let mut response = approval_required_response(&pending.record)?; + let mut ask_completion = IdempotencyAskCompletionGuard::new( + reservation.record.id.clone(), + approval_id.clone(), + response.clone(), + self.idempotency.clone(), + self.background_tasks.clone(), + ); + reservation.disarm(); + let replayed = match self + .idempotency + .complete( + &reservation.record.id, + IdempotencyResponseKind::Approval, + &response, + Some(&approval_id), + ) + .await + { + Ok(_) => { + ask_completion.disarm(); + false + } + Err(IdempotencyError::InvalidTransition("completed")) => { + let replayed = match self + .idempotency + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: &actor_api_token_id, + }, + key, + route: GATEWAY_INVOKE_ROUTE, + callable_path: &lookup.callable_path, + arguments: &call.arguments, + }) + .await? + { + IdempotencyClaim::Replay { + response: replay_response, + .. + } => { + response = replay_response; + true + } + _ => return Err(GatewayInvokeError::InProgress), + }; + ask_completion.disarm(); + replayed + } + Err(error) => { + terminalize_idempotency(&self.idempotency, &reservation.record.id).await; + ask_completion.disarm(); + return Err(error.into()); + } + }; + self.record_attempt( + &call, + Some(lookup.source_id), + Some(lookup.tool_id), + RequestOutcome::PendingApproval, + Some("approval_required"), + Some(approval_id), + started, + ); + drop(preflight); + return Ok(GatewayInvokeResponse { response, replayed }); + } + + drop(preflight); + let lease = match self.catalog.revalidate_invocation(&token).await { + Ok(Some(lease)) => lease, + Ok(None) => { + self.idempotency + .release_reserved(&reservation.record.id) + .await?; + reservation.disarm(); + return Err(ToolCallError::Stale.into()); + } + Err(error) => { + self.idempotency + .release_reserved(&reservation.record.id) + .await?; + reservation.disarm(); + return Err(ToolCallError::Catalog(error).into()); + } + }; + let prepared = match prepare_with_lease(&self.protocols, lease, &call.arguments) { + Ok(prepared) => prepared, + Err(error) => { + self.idempotency + .release_reserved(&reservation.record.id) + .await?; + reservation.disarm(); + return Err(error.into()); + } + }; + self.idempotency + .mark_executing(&reservation.record.id) + .await?; + reservation.disarm(); + let mut execution_guard = IdempotencyExecutionGuard::new( + reservation.record.id.clone(), + self.idempotency.clone(), + self.background_tasks.clone(), + ); + let result = match execute_prepared(&self.protocols, prepared).await { + Ok(result) => result, + Err(error) => { + match self + .idempotency + .mark_indeterminate(&reservation.record.id) + .await + { + Ok(_) | Err(IdempotencyError::InvalidTransition("indeterminate")) => { + execution_guard.disarm(); + } + Err(idempotency_error) => { + tracing::error!( + idempotency_id = reservation.record.id, + error = %idempotency_error, + "failed to mark an uncertain gateway invocation indeterminate" + ); + return Err(idempotency_error.into()); + } + } + self.record_attempt( + &call, + Some(lookup.source_id), + Some(lookup.tool_id), + RequestOutcome::Failed, + Some(error_code(&error)), + None, + started, + ); + return Err(error.into()); + } + }; + let response = tool_result_response(&result)?; + if let Err(error) = self + .idempotency + .complete( + &reservation.record.id, + IdempotencyResponseKind::Tool, + &response, + None, + ) + .await + { + match self + .idempotency + .mark_indeterminate(&reservation.record.id) + .await + { + Ok(_) | Err(IdempotencyError::InvalidTransition("completed" | "indeterminate")) => { + execution_guard.disarm(); + } + Err(mark_error) => return Err(mark_error.into()), + } + return Err(error.into()); + } + execution_guard.disarm(); + self.record_attempt( + &call, + Some(lookup.source_id), + Some(lookup.tool_id), + if result.ok { + RequestOutcome::Succeeded + } else { + RequestOutcome::Failed + }, + (!result.ok).then_some("upstream_http_error"), + None, + started, + ); + Ok(GatewayInvokeResponse { + response, + replayed: false, + }) + } + + async fn reconcile_idempotent_approval( + &self, + record: &IdempotencyRecord, + actor_api_token_id: &str, + key: &str, + callable_path: &str, + arguments: &Value, + ) -> Result, GatewayInvokeError> { + let approval_id = match self.idempotency.correlated_approval_id(record).await? { + Some(CorrelatedApproval::Live(approval_id)) => approval_id, + Some(CorrelatedApproval::Retired) => { + self.idempotency.mark_indeterminate(&record.id).await?; + return Err(GatewayInvokeError::OutcomeUnknown); + } + None => return Ok(None), + }; + let Some(approval) = self + .approvals + .get_admin(&approval_id) + .await + .map_err(ToolCallError::Approval)? + else { + terminalize_idempotency(&self.idempotency, &record.id).await; + return Err(GatewayInvokeError::OutcomeUnknown); + }; + let response = match approval_required_response(&approval.record) { + Ok(response) => response, + Err(_) => { + terminalize_idempotency(&self.idempotency, &record.id).await; + return Err(GatewayInvokeError::OutcomeUnknown); + } + }; + match self + .idempotency + .complete( + &record.id, + IdempotencyResponseKind::Approval, + &response, + Some(&approval_id), + ) + .await + { + Ok(_) => Ok(Some(GatewayInvokeResponse { + response, + replayed: true, + })), + Err(IdempotencyError::InvalidTransition("completed")) => { + match self + .idempotency + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: actor_api_token_id, + }, + key, + route: GATEWAY_INVOKE_ROUTE, + callable_path, + arguments, + }) + .await? + { + IdempotencyClaim::Replay { response, .. } => Ok(Some(GatewayInvokeResponse { + response, + replayed: true, + })), + _ => Err(GatewayInvokeError::InProgress), + } + } + Err(_) => { + terminalize_idempotency(&self.idempotency, &record.id).await; + Err(GatewayInvokeError::OutcomeUnknown) + } + } + } + + pub async fn discover_search( + &self, + call: &ToolCall, + query: String, + namespace: Option, + limit: u32, + offset: u32, + ) -> Result { + let started = Instant::now(); + let permit = match self.discovery_slots.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + self.record_attempt( + call, + None, + None, + RequestOutcome::Failed, + Some("search_busy"), + None, + started, + ); + return Err(ToolDiscoveryError::Busy); + } + }; + let catalog = self.catalog.clone(); + let result = tokio::spawn(async move { + let result = catalog + .search_tools(&query, namespace.as_deref(), limit, offset) + .await; + drop(permit); + result + }) + .await + .map_err(|_| ToolDiscoveryError::Interrupted)?; + self.record_discovery_result(call, started, &result); + result.map_err(ToolDiscoveryError::Catalog) + } + + pub async fn discover_describe( + &self, + call: &ToolCall, + path: &str, + ) -> Result { + let started = Instant::now(); + let result = self.catalog.describe_tool(path).await; + self.record_discovery_result(call, started, &result); + result.map_err(ToolDiscoveryError::Catalog) + } + + pub async fn discover_sources(&self, call: &ToolCall) -> Result { + let started = Instant::now(); + let _permit = match self.discovery_slots.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + self.record_attempt( + call, + None, + None, + RequestOutcome::Failed, + Some("search_busy"), + None, + started, + ); + return Err(ToolDiscoveryError::Busy); + } + }; + let sources = self.catalog.list_sources().await; + let sources = match sources { + Ok(sources) => sources, + Err(error) => { + self.record_attempt( + call, + None, + None, + RequestOutcome::Failed, + Some(catalog_error_code(&error)), + None, + started, + ); + return Err(ToolDiscoveryError::Catalog(error)); + } + }; + let mut visible = Vec::new(); + for source in sources { + let mut tool_count = 0usize; + for mode in [ToolMode::Enabled, ToolMode::Ask] { + let tools = match self + .catalog + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + effective_mode: Some(mode), + limit: 1, + ..Default::default() + }) + .await + { + Ok(tools) => tools, + Err(error) => { + self.record_attempt( + call, + None, + None, + RequestOutcome::Failed, + Some(catalog_error_code(&error)), + None, + started, + ); + return Err(ToolDiscoveryError::Catalog(error)); + } + }; + tool_count = tool_count.saturating_add(tools.total); + } + if tool_count == 0 { + continue; + } + visible.push(json!({ + "id": source.id, + "kind": source.kind, + "slug": source.slug, + "displayName": source.display_name, + "description": source.description, + "toolCount": tool_count, + })); + } + self.record_attempt( + call, + None, + None, + RequestOutcome::Succeeded, + None, + None, + started, + ); + Ok(Value::Array(visible)) + } + + fn record_discovery_result( + &self, + call: &ToolCall, + started: Instant, + result: &Result, + ) { + let (outcome, code) = match result { + Ok(_) => (RequestOutcome::Succeeded, None), + Err(CatalogError::ToolDisabled { .. }) => { + (RequestOutcome::Denied, Some("tool_disabled")) + } + Err(error) => (RequestOutcome::Failed, Some(catalog_error_code(error))), + }; + self.record_attempt(call, None, None, outcome, code, None, started); + } + + pub async fn decide( + &self, + approval_id: &str, + request_id: &str, + expected_revision: i64, + decision: ApprovalDecision, + admin_id: i64, + ) -> Result { + let cancellation_guard = if decision == ApprovalDecision::Approve + && let Some(detail) = self.approvals.get_admin(approval_id).await? + { + let guard = self.deferred_execution_cancellations.read().await; + if guard.contains(&detail.record.execution_id) + || self.execution_is_stopping(&detail.record.execution_id) + { + drop(guard); + self.cancel_execution(&detail.record.execution_id).await?; + return Err(ApprovalError::InvalidTransition { + status: crate::approval::ApprovalStatus::Canceled, + }); + } + Some(guard) + } else { + None + }; + let result = self + .approvals + .decide( + approval_id, + request_id, + expected_revision, + decision, + admin_id, + ) + .await; + drop(cancellation_guard); + let result = result?; + if result.record.status.is_terminal() { + self.schedule_approval_log_flush_if_pending().await?; + } + if result.record.status == crate::approval::ApprovalStatus::Approved && !result.idempotent { + self.spawn_approved(result.record.id.clone()); + } + self.notify_approval(approval_id); + self.approvals + .get_admin(approval_id) + .await? + .ok_or(ApprovalError::NotFound) + } + + pub async fn cancel_execution(&self, execution_id: &str) -> Result { + let affected = self.approvals.cancel_execution(execution_id).await?; + if affected > 0 { + self.schedule_approval_log_flush(); + self.global_approval_notify.notify_waiters(); + } + Ok(affected) + } + + pub(crate) async fn mark_execution_lost(&self, execution_id: &str) { + self.execution_stopping_flag(execution_id) + .store(true, Ordering::Release); + self.deferred_execution_cancellations + .write() + .await + .insert(execution_id.to_owned()); + } + + pub(crate) async fn cancel_lost_execution(&self, execution_id: &str) { + self.mark_execution_lost(execution_id).await; + let mut retry_delay = Duration::from_millis(50); + for attempt in 0..3 { + match self.cancel_execution(execution_id).await { + Ok(_) => { + self.deferred_execution_cancellations + .write() + .await + .remove(execution_id); + self.clear_execution_stopping_flag(execution_id); + return; + } + Err(error) => { + tracing::warn!( + execution_id, + error = %error, + "execution approval cleanup failed" + ); + if attempt < 2 { + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(5)); + } + } + } + } + let service = self.clone(); + let deferred_execution_id = execution_id.to_owned(); + if !self.background_tasks.spawn(async move { + let mut retry_delay = Duration::from_millis(100); + loop { + match service.cancel_execution(&deferred_execution_id).await { + Ok(_) => { + service + .deferred_execution_cancellations + .write() + .await + .remove(&deferred_execution_id); + service.clear_execution_stopping_flag(&deferred_execution_id); + return; + } + Err(error) => { + tracing::warn!( + execution_id = deferred_execution_id, + error = %error, + "deferred execution approval cleanup failed" + ); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(5)); + } + } + } + }) { + tracing::warn!( + execution_id, + "execution cleanup deferred until startup recovery" + ); + } + } + + pub(crate) fn execution_stopping_flag(&self, execution_id: &str) -> Arc { + self.execution_stopping_flags + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(execution_id.to_owned()) + .or_insert_with(|| Arc::new(AtomicBool::new(false))) + .clone() + } + + fn execution_is_stopping(&self, execution_id: &str) -> bool { + self.execution_stopping_flags + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(execution_id) + .is_some_and(|stopping| stopping.load(Ordering::Acquire)) + } + + pub(crate) fn clear_execution_stopping_flag(&self, execution_id: &str) { + self.execution_stopping_flags + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(execution_id); + } + + pub async fn expire_approvals(&self) -> Result { + let affected = self.approvals.expire_pending().await?; + if affected > 0 { + self.schedule_approval_log_flush(); + self.global_approval_notify.notify_waiters(); + } + Ok(affected) + } + + pub async fn revoke_owner_token( + &self, + actor_api_token_id: &str, + ) -> Result { + let revoked = self + .approvals + .revoke_owner_token(actor_api_token_id) + .await?; + if revoked { + self.schedule_approval_log_flush(); + self.global_approval_notify.notify_waiters(); + } + Ok(revoked) + } + + pub async fn cancel_approval( + &self, + approval_id: &str, + actor_api_token_id: &str, + expected_revision: i64, + ) -> Result { + let record = self + .approvals + .cancel_token_owner(approval_id, actor_api_token_id, expected_revision) + .await?; + self.schedule_approval_log_flush(); + self.notify_approval(approval_id); + Ok(record) + } + + pub async fn wait_for_approval( + &self, + mut approval: ApprovalRequired, + actor: &ToolActor, + execution_id: &str, + call_id: &str, + cancellation: C, + ) -> Result + where + C: Future + Send, + { + let approval_id = approval.id.clone(); + if approval.actor_kind != actor.kind() + || approval.actor_id != actor.id() + || approval.execution_id != execution_id + || approval.call_id != call_id + { + return Err(ApprovalError::NotFound.into()); + } + tokio::pin!(cancellation); + let registration = self.register_approval_wait(&approval_id); + loop { + let targeted_notification = registration.notify.notified(); + tokio::pin!(targeted_notification); + targeted_notification.as_mut().enable(); + let global_notification = self.global_approval_notify.notified(); + tokio::pin!(global_notification); + global_notification.as_mut().enable(); + let detail = self + .approvals + .get_for_actor(&approval_id, actor) + .await? + .ok_or(ApprovalError::NotFound)?; + if detail.record.execution_id != execution_id || detail.record.call_id != call_id { + return Err(ApprovalError::NotFound.into()); + } + if detail.record.status.is_terminal() { + if let Some(delivery) = approval.delivery.as_mut() { + delivery.release().await; + } + return Ok(detail); + } + let now = crate::unix_timestamp(); + let until_expiry = u64::try_from(detail.record.expires_at.saturating_sub(now)) + .unwrap_or(0) + .clamp(1, 30); + tokio::select! { + () = &mut cancellation => { + if let Some(delivery) = approval.delivery.as_mut() { + delivery.release().await; + } + return Err(ApprovalWaitError::Canceled); + }, + () = &mut targeted_notification => {} + () = &mut global_notification => {} + () = tokio::time::sleep(Duration::from_secs(until_expiry)) => { + let should_scan = { + let now = Instant::now(); + let mut next_scan = self + .next_expiry_scan + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if now >= *next_scan { + *next_scan = now + Duration::from_secs(30); + true + } else { + false + } + }; + if should_scan + && let Ok(_permit) = self.expiry_slot.clone().try_acquire_owned() + { + self.expire_approvals().await?; + } + } + } + } + } + + fn spawn_approved(&self, approval_id: String) { + if !self + .in_flight_approvals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(approval_id.clone()) + { + return; + } + let worker_service = self.clone(); + let worker_approval_id = approval_id.clone(); + let (worker_completed, worker_result) = oneshot::channel(); + let worker = async move { + let Ok(_permit) = worker_service.execution_slots.clone().acquire_owned().await else { + let _ = worker_completed.send(Err(ToolCallError::Approval( + ApprovalError::InvalidTransition { + status: crate::approval::ApprovalStatus::Approved, + }, + ))); + return; + }; + let result = worker_service.execute_approved(&worker_approval_id).await; + let _ = worker_completed.send(result); + }; + let monitor_service = self.clone(); + let monitor_approval_id = approval_id.clone(); + let monitor = async move { + let failure_code = match worker_result.await { + Ok(Ok(())) => None, + Ok(Err(error)) => { + tracing::warn!(approval_id = monitor_approval_id, error = %error, "approved tool call did not execute"); + Some("execution_task_failed") + } + Err(_) => { + tracing::error!( + approval_id = monitor_approval_id, + "approved tool call task stopped unexpectedly" + ); + Some("execution_task_interrupted") + } + }; + if let Some(failure_code) = failure_code { + monitor_service + .finalize_abandoned_execution(&monitor_approval_id, failure_code) + .await; + } + monitor_service + .in_flight_approvals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&monitor_approval_id); + }; + if !self.background_tasks.spawn_pair(worker, monitor) { + self.in_flight_approvals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&approval_id); + } + } + + async fn finalize_abandoned_execution(&self, approval_id: &str, failure_code: &str) { + for attempt in 0..3 { + let result = async { + let Some(detail) = self.approvals.get_admin(approval_id).await? else { + return Ok::<_, ApprovalError>(()); + }; + match detail.record.status { + crate::approval::ApprovalStatus::Approved => { + self.approvals + .mark_stale(approval_id, detail.record.revision, failure_code) + .await?; + } + crate::approval::ApprovalStatus::Executing => { + self.approvals + .finish( + approval_id, + detail.record.revision, + ExecutionOutcome::Interrupted, + &serde_json::to_value(error_result(&ToolCallError::Stale)) + .map_err(ApprovalError::Json)?, + Some(failure_code), + ) + .await?; + } + _ => {} + } + self.schedule_approval_log_flush_if_pending().await?; + Ok(()) + } + .await; + match result { + Ok(()) => { + self.notify_approval(approval_id); + return; + } + Err(error) if attempt < 2 => { + tracing::warn!(approval_id, error = %error, "retrying approval task finalization"); + tokio::time::sleep(Duration::from_millis(50 * (attempt + 1))).await; + } + Err(error) => { + tracing::error!(approval_id, error = %error, "approval task finalization failed"); + } + } + } + } + + async fn execute_approved(&self, approval_id: &str) -> Result<(), ToolCallError> { + let Some(detail) = self.approvals.get_admin(approval_id).await? else { + return Ok(()); + }; + let record = detail.record; + if record.status != crate::approval::ApprovalStatus::Approved { + return Ok(()); + } + let expected_revision = record.revision; + let token = record.revisions.clone().into(); + let lease = match self.catalog.revalidate_invocation(&token).await { + Ok(Some(lease)) => lease, + Ok(None) => { + self.approvals + .mark_stale(approval_id, expected_revision, "approval_stale") + .await?; + self.schedule_approval_log_flush(); + self.notify_approval(approval_id); + self.wait_for_delivery_release(approval_id).await?; + return Ok(()); + } + Err(error) => { + let failure_code = error_code(&ToolCallError::Catalog(error)); + self.approvals + .mark_stale(approval_id, expected_revision, failure_code) + .await?; + self.schedule_approval_log_flush(); + self.notify_approval(approval_id); + self.wait_for_delivery_release(approval_id).await?; + return Ok(()); + } + }; + let cancellation_guard = self.deferred_execution_cancellations.read().await; + if cancellation_guard.contains(&record.execution_id) + || self.execution_is_stopping(&record.execution_id) + { + drop(cancellation_guard); + self.cancel_execution(&record.execution_id).await?; + self.wait_for_delivery_release(approval_id).await?; + return Ok(()); + } + let snapshot = match self + .approvals + .claim_execution(approval_id, record.worker_generation, expected_revision) + .await + { + Ok(snapshot) => snapshot, + Err( + ApprovalError::RevisionConflict { .. } | ApprovalError::InvalidTransition { .. }, + ) => { + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + if self.execution_is_stopping(&record.execution_id) { + drop(cancellation_guard); + let result = ToolResult { + ok: false, + data: None, + error: Some(PublicToolError { + code: "execution_cancelled".to_owned(), + message: "The execution was canceled before the tool call started.".to_owned(), + }), + http: None, + }; + self.approvals + .finish( + approval_id, + snapshot.record.revision, + ExecutionOutcome::Interrupted, + &serde_json::to_value(result).map_err(ApprovalError::Json)?, + Some("execution_cancelled"), + ) + .await?; + self.schedule_approval_log_flush(); + self.notify_approval(approval_id); + self.wait_for_delivery_release(approval_id).await?; + return Ok(()); + } + drop(cancellation_guard); + if !lease.arguments_are_valid(&snapshot.arguments) { + let result = error_result(&ToolCallError::InvalidArguments); + self.approvals + .finish( + approval_id, + snapshot.record.revision, + ExecutionOutcome::Failed, + &serde_json::to_value(result).map_err(ApprovalError::Json)?, + Some("invalid_tool_arguments"), + ) + .await?; + self.schedule_approval_log_flush(); + self.notify_approval(approval_id); + self.wait_for_delivery_release(approval_id).await?; + return Ok(()); + } + let result = execute_with_lease(&self.protocols, lease, &snapshot.arguments).await; + let (tool_result, outcome, failure_code) = match result { + Ok(result) if result.ok => (result, ExecutionOutcome::Succeeded, None), + Ok(result) => ( + result, + ExecutionOutcome::Failed, + Some("upstream_http_error"), + ), + Err(error) => ( + error_result(&error), + ExecutionOutcome::Failed, + Some(error_code(&error)), + ), + }; + let result_json = serde_json::to_value(&tool_result).map_err(ApprovalError::Json)?; + self.approvals + .finish( + approval_id, + snapshot.record.revision, + outcome, + &result_json, + failure_code, + ) + .await?; + self.schedule_approval_log_flush(); + self.notify_approval(approval_id); + self.wait_for_delivery_release(approval_id).await?; + Ok(()) + } + + async fn wait_for_delivery_release(&self, approval_id: &str) -> Result<(), ApprovalError> { + let registration = self.register_approval_wait(approval_id); + loop { + let notification = registration.notify.notified(); + tokio::pin!(notification); + notification.as_mut().enable(); + if !self.approvals.delivery_pin_exists(approval_id).await? { + return Ok(()); + } + notification.await; + } + } + + fn register_approval_wait(&self, approval_id: &str) -> ApprovalWaitRegistration { + let mut registry = self + .approval_notifications + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let entry = + registry + .entry(approval_id.to_owned()) + .or_insert_with(|| ApprovalNotificationEntry { + notify: Arc::new(Notify::new()), + waiters: 0, + }); + entry.waiters = entry.waiters.saturating_add(1); + let notify = entry.notify.clone(); + ApprovalWaitRegistration { + approval_id: approval_id.to_owned(), + notify, + registry: self.approval_notifications.clone(), + } + } + + fn notify_approval(&self, approval_id: &str) { + notify_registry(&self.approval_notifications, approval_id); + } + + #[allow(clippy::too_many_arguments)] + fn record_attempt( + &self, + call: &ToolCall, + source_id: Option, + tool_id: Option, + outcome: RequestOutcome, + error_code: Option<&str>, + approval_id: Option, + started: Instant, + ) { + let path = normalized_path_snapshot(&call.path); + self.record(NewRequestLog { + request_id: call.request_id.clone(), + actor_api_token_id: call.actor.api_token_id().map(str::to_owned), + surface: call.surface, + source_id, + tool_id, + path_snapshot: Some(path), + outcome, + error_code: error_code.map(str::to_owned), + duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + approval_id, + created_at: crate::unix_timestamp(), + }); + } + + fn record(&self, log: NewRequestLog) { + self.request_logs.try_record(log); + } + + fn schedule_approval_log_flush(&self) { + self.approval_log_requested.store(true, Ordering::Release); + let Ok(permit) = self.approval_log_flush.clone().try_acquire_owned() else { + return; + }; + let service = self.clone(); + self.background_tasks.spawn(async move { + service.flush_approval_logs(permit).await; + }); + } + + async fn schedule_approval_log_flush_if_pending(&self) -> Result<(), ApprovalError> { + if !self.approvals.log_outbox(1).await?.is_empty() { + self.schedule_approval_log_flush(); + } + Ok(()) + } + + async fn flush_approval_logs(&self, permit: OwnedSemaphorePermit) { + let mut retry_delay = Duration::from_millis(50); + loop { + self.approval_log_requested.store(false, Ordering::Release); + let events = match self.approvals.log_outbox(256).await { + Ok(events) => events, + Err(error) => { + tracing::warn!(error = %error, "approval log outbox could not be read"); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(5)); + continue; + } + }; + if events.is_empty() { + if self.approval_log_requested.load(Ordering::Acquire) { + continue; + } + drop(permit); + if self.approval_log_requested.swap(false, Ordering::AcqRel) { + self.schedule_approval_log_flush(); + } + return; + } + for event in events { + let request_id = event.request_id.clone(); + while !self.request_logs.record_durable(event.clone()).await { + tracing::warn!( + request_id, + "approval terminal log persistence will be retried" + ); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(5)); + } + loop { + match self.approvals.acknowledge_log_outbox(&request_id).await { + Ok(()) => break, + Err(error) => { + tracing::warn!(request_id, error = %error, "approval log outbox acknowledgement will be retried"); + tokio::time::sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(Duration::from_secs(5)); + } + } + } + retry_delay = Duration::from_millis(50); + } + } + } +} + +fn notify_registry(registry: &ApprovalNotificationRegistry, approval_id: &str) { + let notify = registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(approval_id) + .map(|entry| entry.notify.clone()); + if let Some(notify) = notify { + notify.notify_waiters(); + } +} + +pub(crate) async fn execute_with_lease( + protocols: &ProtocolRegistry, + lease: InvocationLease, + arguments: &Value, +) -> Result { + let prepared = prepare_with_lease(protocols, lease, arguments)?; + execute_prepared(protocols, prepared).await +} + +fn prepare_with_lease( + protocols: &ProtocolRegistry, + lease: InvocationLease, + arguments: &Value, +) -> Result { + protocols + .prepare_invocation(lease, arguments) + .map_err(ToolCallError::Protocol) +} + +async fn execute_prepared( + protocols: &ProtocolRegistry, + prepared: PreparedProtocolInvocation, +) -> Result { + let response = protocols + .execute_invocation(prepared) + .await + .map_err(|error| match error { + ProtocolInvocationError::Outbound(error) => ToolCallError::Outbound(error), + })?; + let result = ToolResult { + ok: response.ok, + data: response.data, + error: response.error.map(|error| PublicToolError { + code: error.code, + message: error.message, + }), + http: response.http.map(|http| ToolHttpMetadata { + status: http.status, + headers: http.headers, + truncated: http.truncated, + }), + }; + if serde_json::to_vec(&result).is_ok_and(|encoded| encoded.len() > MAX_RESULT_BYTES) { + return Err(ToolCallError::ResultTooLarge); + } + Ok(result) +} + +fn tool_result_response(result: &ToolResult) -> Result { + Ok(IdempotencyResponse { + status: 200, + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: serde_json::to_vec(result)?, + }) +} + +fn approval_required_response( + approval: &ApprovalRecord, +) -> Result { + Ok(IdempotencyResponse { + status: 202, + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: serde_json::to_vec(&json!({ + "status": "approval_required", + "approval": { + "id": approval.id, + "status": ApprovalStatus::Pending, + "revision": 0, + "path": approval.callable_path_snapshot, + "createdAt": approval.created_at, + "expiresAt": approval.expires_at, + "statusUrl": format!("/api/v1/gateway/approvals/{}", approval.id), + } + }))?, + }) +} + +fn normalized_path_snapshot(path: &str) -> String { + let path = if path.starts_with("tools.") { + path.to_owned() + } else { + format!("tools.{path}") + }; + if path.len() <= 512 && !path.contains('\0') { + path + } else { + "tools.invoke".to_owned() + } +} + +async fn token_is_active(pool: &SqlitePool, token_id: &str) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM api_tokens WHERE id = ? AND revoked_at IS NULL)", + ) + .bind(token_id) + .fetch_one(pool) + .await + .map(|active| active != 0) + .map_err(ApprovalError::Database) +} + +fn idempotency_startup_error(error: IdempotencyError) -> ApprovalError { + match error { + IdempotencyError::Database(error) => ApprovalError::Database(error), + IdempotencyError::Crypto(error) => ApprovalError::Crypto(error), + IdempotencyError::Json(error) => ApprovalError::Json(error), + IdempotencyError::InvalidKey + | IdempotencyError::InvalidMetadata + | IdempotencyError::PayloadTooLarge + | IdempotencyError::Capacity + | IdempotencyError::NotFound + | IdempotencyError::InvalidTransition(_) + | IdempotencyError::CorruptData => { + ApprovalError::CorruptData("gateway idempotency recovery") + } + } +} + +fn error_code(error: &ToolCallError) -> &'static str { + match error { + ToolCallError::Approval(_) => "approval_error", + ToolCallError::Catalog(CatalogError::Validation { code, .. }) => code, + ToolCallError::Catalog(CatalogError::NotFound { entity: "source" }) => "source_not_found", + ToolCallError::Catalog(CatalogError::NotFound { .. }) + | ToolCallError::Catalog(CatalogError::ToolNotFound { .. }) => "tool_not_found", + ToolCallError::Catalog(CatalogError::ToolDisabled { .. }) => "tool_disabled", + ToolCallError::Catalog(CatalogError::RevisionConflict { .. }) => "revision_conflict", + ToolCallError::Catalog(_) => "catalog_error", + ToolCallError::Adapter { code, .. } => code, + ToolCallError::ArgumentsTooLarge => "arguments_too_large", + ToolCallError::InvalidArguments => "invalid_tool_arguments", + ToolCallError::Stale => "invocation_stale", + ToolCallError::Outbound(error) => error.code(), + ToolCallError::Protocol(error) => error.code, + ToolCallError::ResultTooLarge => "result_too_large", + } +} + +fn catalog_error_code(error: &CatalogError) -> &'static str { + match error { + CatalogError::Validation { code, .. } => code, + CatalogError::NotFound { entity: "source" } => "source_not_found", + CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => "tool_not_found", + CatalogError::ToolDisabled { .. } => "tool_disabled", + CatalogError::RevisionConflict { .. } => "revision_conflict", + CatalogError::Database(_) + | CatalogError::Crypto(_) + | CatalogError::Json(_) + | CatalogError::CorruptData(_) => "internal_error", + } +} + +fn error_result(error: &ToolCallError) -> ToolResult { + ToolResult { + ok: false, + data: None, + error: Some(PublicToolError { + code: error_code(error).to_owned(), + message: "The tool call could not be completed safely.".to_owned(), + }), + http: None, + } +} + +#[cfg(test)] +mod idempotency_guard_tests { + use std::{future::pending, time::Duration}; + + use serde_json::json; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + use super::*; + + static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + + #[tokio::test] + async fn canceled_pre_execution_task_releases_its_reservation() { + let directory = tempfile::tempdir().expect("temporary directory"); + let pool = SqlitePoolOptions::new() + .max_connections(4) + .connect_with( + SqliteConnectOptions::new() + .filename(directory.path().join("guard.db")) + .create_if_missing(true) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(2)), + ) + .await + .expect("database pool"); + MIGRATOR.run(&pool).await.expect("migrations"); + sqlx::query( + "INSERT INTO api_tokens (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('owner', 'Owner', zeroblob(32), 'tok_', 'tail', 1)", + ) + .execute(&pool) + .await + .expect("owner token"); + let store = GatewayIdempotencyStore::new( + pool.clone(), + Keyring::from_master_key([9; 32]).expect("keyring"), + ); + let arguments = json!({}); + let claim = store + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: "owner", + }, + key: "canceled-call", + route: GATEWAY_INVOKE_ROUTE, + callable_path: "tools.source.tool", + arguments: &arguments, + }) + .await + .expect("reservation"); + let IdempotencyClaim::Fresh(record) = claim else { + panic!("claim must be fresh"); + }; + let tasks = TaskTracker::default(); + let (ready, waiting) = oneshot::channel(); + let guard_store = store.clone(); + let guard_tasks = tasks.clone(); + let guard_approvals = ApprovalStore::system( + pool.clone(), + Keyring::from_master_key([9; 32]).expect("guard keyring"), + ); + let task = tokio::spawn(async move { + let _guard = + IdempotencyReservationGuard::new(record, guard_store, guard_approvals, guard_tasks); + let _ = ready.send(()); + pending::<()>().await; + }); + waiting.await.expect("guard task starts"); + task.abort(); + let _ = task.await; + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM gateway_invocation_idempotency", + ) + .fetch_one(&pool) + .await + .expect("idempotency rows count"); + if count == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("reservation is released without restart"); + + let execution_claim = store + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: "owner", + }, + key: "canceled-execution", + route: GATEWAY_INVOKE_ROUTE, + callable_path: "tools.source.tool", + arguments: &arguments, + }) + .await + .expect("execution reservation"); + let IdempotencyClaim::Fresh(execution_record) = execution_claim else { + panic!("execution claim must be fresh"); + }; + store + .mark_executing(&execution_record.id) + .await + .expect("execution boundary"); + let (ready, waiting) = oneshot::channel(); + let execution_store = store.clone(); + let execution_tasks = tasks.clone(); + let execution_id = execution_record.id.clone(); + let task = tokio::spawn(async move { + let _guard = + IdempotencyExecutionGuard::new(execution_id, execution_store, execution_tasks); + let _ = ready.send(()); + pending::<()>().await; + }); + waiting.await.expect("execution guard task starts"); + task.abort(); + let _ = task.await; + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(&execution_record.id) + .fetch_one(&pool) + .await + .expect("execution state reads"); + if state == "indeterminate" { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("aborted execution becomes indeterminate without restart"); + + let ask_claim = store + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: "owner", + }, + key: "canceled-ask-completion", + route: GATEWAY_INVOKE_ROUTE, + callable_path: "tools.source.tool", + arguments: &arguments, + }) + .await + .expect("Ask reservation"); + let IdempotencyClaim::Fresh(ask_record) = ask_claim else { + panic!("Ask claim must be fresh"); + }; + sqlx::query( + "INSERT INTO approvals ( \ + id, execution_id, call_id, worker_generation, actor_kind, actor_id, \ + actor_api_token_id, surface, source_id, tool_id, callable_path_snapshot, \ + mode_provenance, source_revision, catalog_revision, tool_revision, \ + binding_revision, arguments_digest, arguments_ciphertext, \ + redacted_arguments_ciphertext, input_schema_ciphertext, \ + invocation_snapshot_ciphertext, status, created_at, updated_at, expires_at \ + ) VALUES ( \ + 'guard-approval', ?, 'gateway', 0, 'api_token', 'owner', 'owner', \ + 'gateway', 'source', 'tool', 'tools.source.tool', 'intrinsic', \ + 0, 0, 0, 0, zeroblob(32), zeroblob(42), zeroblob(42), zeroblob(42), \ + zeroblob(42), 'pending', 1, 1, 601 \ + )", + ) + .bind(idempotency_execution_id(&ask_record.id)) + .execute(&pool) + .await + .expect("Ask approval fixture"); + let response = IdempotencyResponse { + status: 202, + headers: BTreeMap::new(), + body: b"approval required".to_vec(), + }; + let mut lock = pool.acquire().await.expect("writer connection"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *lock) + .await + .expect("writer lock"); + let (ready, waiting) = oneshot::channel(); + let completion_store = store.clone(); + let completion_tasks = tasks.clone(); + let completion_record = ask_record.clone(); + let completion_response = response.clone(); + let task = tokio::spawn(async move { + let mut guard = IdempotencyAskCompletionGuard::new( + completion_record.id.clone(), + "guard-approval".to_owned(), + completion_response.clone(), + completion_store.clone(), + completion_tasks, + ); + let _ = ready.send(()); + if completion_store + .complete( + &completion_record.id, + IdempotencyResponseKind::Approval, + &completion_response, + Some("guard-approval"), + ) + .await + .is_ok() + { + guard.disarm(); + } + }); + waiting.await.expect("Ask completion starts"); + tokio::time::sleep(Duration::from_millis(25)).await; + task.abort(); + let _ = task.await; + sqlx::query("ROLLBACK") + .execute(&mut *lock) + .await + .expect("writer lock releases"); + drop(lock); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(&ask_record.id) + .fetch_one(&pool) + .await + .expect("Ask completion state reads"); + if state == "completed" { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped Ask completion is durably finished"); + tasks.shutdown().await; + } +} diff --git a/src/lib.rs b/src/lib.rs index e8ef1be55..e1d32090f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,12 +12,20 @@ use sqlx::SqlitePool; use thiserror::Error; use url::Url; +pub mod actor; mod api; +pub mod approval; pub mod catalog; pub mod crypto; mod database; +pub mod execution; +pub use approval::invocation; pub mod openapi; pub mod outbound; +pub(crate) mod protocols; +mod request_logs; +pub mod runtime; +mod tasks; pub mod web_assets; pub use database::DatabaseError; @@ -32,6 +40,7 @@ pub struct AppConfig { pub(crate) origin: Url, pub(crate) session_ttl_seconds: i64, pub(crate) trusted_proxies: Arc<[IpNet]>, + pub(crate) runtime_executable: Option, } impl AppConfig { @@ -42,6 +51,7 @@ impl AppConfig { origin: Url::parse(DEFAULT_ORIGIN).expect("the default public origin is valid"), session_ttl_seconds: 8 * 60 * 60, trusted_proxies: Arc::from([]), + runtime_executable: None, } } @@ -61,6 +71,11 @@ impl AppConfig { self } + pub fn with_runtime_executable(mut self, executable: PathBuf) -> Self { + self.runtime_executable = Some(executable); + self + } + pub fn with_origin(mut self, origin: &str) -> Result { self.origin = parse_public_origin(origin)?; Ok(self) @@ -117,6 +132,9 @@ pub struct ExecutorApp { setup_token: Option, pool: SqlitePool, catalog: catalog::CatalogStore, + tool_calls: invocation::ToolCallService, + execution: execution::ExecutionService, + api_tasks: tasks::TaskTracker, } impl ExecutorApp { @@ -124,10 +142,31 @@ impl ExecutorApp { let opened = database::Database::open(&config).await?; let pool = opened.database.pool.clone(); let catalog = catalog::CatalogStore::new(pool.clone(), opened.database.keyring.clone()); + let sources = protocols::SourceService::new(catalog.clone()); + let protocol_registry = sources.registry().clone(); + let request_logs = + request_logs::RequestLogSink::new(opened.database.clone(), catalog.clone()); + let tool_calls = invocation::ToolCallService::new( + catalog.clone(), + protocol_registry, + pool.clone(), + opened.database.keyring.clone(), + request_logs.clone(), + ); + tool_calls.recover_startup().await?; + let api_tasks = tasks::TaskTracker::default(); + let runtime = + runtime::RuntimeManager::new(config.runtime_executable.clone().unwrap_or_else(|| { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("executor")) + })); + let execution = execution::ExecutionService::new(runtime, tool_calls.clone()); let dummy_password_hash = crypto::hash_password("executor-dummy-login-password")?; let router = api::router( opened.database, catalog.clone(), + api::ApiServices::new(sources, tool_calls.clone(), execution.clone()), + request_logs, + api_tasks.clone(), &config, dummy_password_hash, ); @@ -136,6 +175,9 @@ impl ExecutorApp { setup_token: opened.setup_token, pool, catalog, + tool_calls, + execution, + api_tasks, }) } @@ -154,6 +196,29 @@ impl ExecutorApp { pub fn catalog(&self) -> &catalog::CatalogStore { &self.catalog } + + pub fn tool_calls(&self) -> &invocation::ToolCallService { + &self.tool_calls + } + + pub fn executions(&self) -> &execution::ExecutionService { + &self.execution + } + + pub async fn shutdown(self) { + self.execution.shutdown().await; + self.api_tasks.shutdown().await; + self.tool_calls.shutdown().await; + self.pool.close().await; + } +} + +impl Drop for ExecutorApp { + fn drop(&mut self) { + self.execution.cancel_all(); + self.api_tasks.abort_all(); + self.tool_calls.abort_background_tasks(); + } } fn parse_public_origin(origin: &str) -> Result { diff --git a/src/main.rs b/src/main.rs index 10e688df7..5bb266930 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,8 @@ -use std::{net::SocketAddr, path::PathBuf}; +use std::{ + net::SocketAddr, + os::fd::{FromRawFd, OwnedFd, RawFd}, + path::PathBuf, +}; use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand}; @@ -18,6 +22,16 @@ struct Cli { #[derive(Subcommand)] enum Command { Server(ServerArgs), + #[command(hide = true)] + SandboxWorker(SandboxWorkerArgs), +} + +#[derive(Args)] +struct SandboxWorkerArgs { + #[arg(long)] + ipc_fd: std::os::fd::RawFd, + #[arg(long)] + generation: u64, } #[derive(Args)] @@ -52,9 +66,44 @@ async fn main() -> Result<()> { match Cli::parse().command { Command::Server(args) => run_server(args).await, + Command::SandboxWorker(args) => { + let ipc = take_worker_socket(args.ipc_fd)?; + executor::runtime::worker_main(ipc, args.generation) + .await + .map_err(anyhow::Error::from) + } } } +fn take_worker_socket(raw_fd: RawFd) -> Result { + if raw_fd != 3 || unsafe { libc::fcntl(raw_fd, libc::F_GETFD) } == -1 { + anyhow::bail!("sandbox IPC descriptor is invalid"); + } + let mut socket_type = 0; + let mut socket_type_length = std::mem::size_of::() as libc::socklen_t; + let socket_result = unsafe { + libc::getsockopt( + raw_fd, + libc::SOL_SOCKET, + libc::SO_TYPE, + std::ptr::from_mut(&mut socket_type).cast(), + &mut socket_type_length, + ) + }; + let mut peer = std::mem::MaybeUninit::::uninit(); + let mut peer_length = std::mem::size_of::() as libc::socklen_t; + let peer_result = + unsafe { libc::getpeername(raw_fd, peer.as_mut_ptr().cast(), &mut peer_length) }; + if socket_result == -1 || socket_type != libc::SOCK_STREAM || peer_result == -1 { + anyhow::bail!("sandbox IPC descriptor is not a connected stream socket"); + } + let peer = unsafe { peer.assume_init() }; + if i32::from(peer.ss_family) != libc::AF_UNIX { + anyhow::bail!("sandbox IPC descriptor is not a Unix socket"); + } + Ok(unsafe { OwnedFd::from_raw_fd(raw_fd) }) +} + async fn run_server(args: ServerArgs) -> Result<()> { let mut config = match args.data_dir { Some(data_dir) => AppConfig::new(data_dir), @@ -84,11 +133,40 @@ async fn run_server(args: ServerArgs) -> Result<()> { } info!(address = %bound_address, "Executor is listening"); - axum::serve( + let server_result = axum::serve( listener, app.router() .into_make_service_with_connect_info::(), ) + .with_graceful_shutdown(shutdown_signal()) .await - .context("Executor server stopped unexpectedly") + .context("Executor server stopped unexpectedly"); + app.shutdown().await; + server_result +} + +#[cfg(unix)] +async fn shutdown_signal() { + let mut terminate = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(terminate) => terminate, + Err(error) => { + tracing::error!(error = %error, "could not listen for the terminate signal"); + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + result = tokio::signal::ctrl_c() => { + if let Err(error) = result { + tracing::error!(error = %error, "could not listen for the interrupt signal"); + } + } + _ = terminate.recv() => {} + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; } diff --git a/src/openapi.rs b/src/openapi.rs index 34cfe4439..84d50ff62 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -50,7 +50,7 @@ pub struct CompiledOpenApiTool { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiBinding { pub version: u32, pub method: String, @@ -62,7 +62,7 @@ pub struct OpenApiBinding { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiParameterBinding { pub name: String, pub location: OpenApiParameterLocation, @@ -93,7 +93,7 @@ impl OpenApiParameterLocation { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiRequestBodyBinding { pub required: bool, pub default_media_type: String, @@ -101,13 +101,13 @@ pub struct OpenApiRequestBodyBinding { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiSecurityAlternative { pub requirements: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiSecurityRequirement { pub scheme_name: String, pub scopes: Vec, @@ -119,7 +119,8 @@ pub struct OpenApiSecurityRequirement { #[serde( tag = "type", rename_all = "snake_case", - rename_all_fields = "camelCase" + rename_all_fields = "camelCase", + deny_unknown_fields )] pub enum OpenApiSecurityScheme { ApiKey { @@ -138,7 +139,7 @@ pub enum OpenApiSecurityScheme { } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiOAuthFlows { pub implicit: Option, pub password: Option, @@ -147,7 +148,7 @@ pub struct OpenApiOAuthFlows { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiOAuthFlow { pub authorization_url: Option, pub token_url: Option, @@ -182,9 +183,8 @@ pub enum OpenApiError { } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OpenApiCredentialSet { - #[serde(default)] pub schemes: BTreeMap, } @@ -192,7 +192,8 @@ pub struct OpenApiCredentialSet { #[serde( tag = "type", rename_all = "snake_case", - rename_all_fields = "camelCase" + rename_all_fields = "camelCase", + deny_unknown_fields )] pub enum OpenApiCredential { ApiKey { @@ -207,7 +208,7 @@ pub enum OpenApiCredential { }, #[serde(rename = "oauth_access_token")] OAuthAccessToken { - #[serde(rename = "access_token", alias = "accessToken")] + #[serde(rename = "access_token")] access_token: String, }, } diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs new file mode 100644 index 000000000..2f6fefe49 --- /dev/null +++ b/src/protocols/mod.rs @@ -0,0 +1,606 @@ +pub mod openapi; + +use std::{collections::BTreeMap, fmt}; + +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::{ + catalog::{ + AuditContext, CatalogError, CatalogStore, CatalogSyncResult, InvocationLease, SourceKind, + SourceRecord, + }, + outbound::OutboundError, +}; + +pub use openapi::{OpenApiPreview, OpenApiSpecInput}; + +#[derive(Clone, Debug)] +pub struct ProtocolExecutionResponse { + pub ok: bool, + pub data: Option, + pub error: Option, + pub http: Option, +} + +#[derive(Clone, Debug)] +pub struct ProtocolResponseError { + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug)] +pub struct ProtocolHttpMetadata { + pub status: u16, + pub headers: BTreeMap, + pub truncated: bool, +} + +#[must_use = "a prepared protocol invocation must be executed or explicitly discarded"] +pub struct PreparedProtocolInvocation { + lease: InvocationLease, + execution: PreparedProtocolExecution, +} + +enum PreparedProtocolExecution { + OpenApi(openapi::PreparedOpenApiInvocation), +} + +#[derive(Debug)] +pub enum ProtocolInvocationError { + Outbound(OutboundError), +} + +impl fmt::Display for ProtocolInvocationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Outbound(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for ProtocolInvocationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Outbound(error) => Some(error), + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialMetadata { + pub revision: i64, + pub configured_schemes: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfiguredCredential { + pub name: String, + pub credential_type: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtocolErrorCategory { + InvalidInput, + NotFound, + Conflict, + CorruptData, + Unsupported, + Upstream, + Internal, +} + +#[derive(Debug)] +pub struct ProtocolError { + pub code: &'static str, + pub message: String, + pub category: ProtocolErrorCategory, +} + +impl ProtocolError { + pub(crate) fn new( + category: ProtocolErrorCategory, + code: &'static str, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + category, + } + } + + pub(crate) fn corrupt(code: &'static str, message: &'static str) -> Self { + Self::new(ProtocolErrorCategory::CorruptData, code, message) + } +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ProtocolError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtocolRegistration { + OpenApi, + GraphqlUnsupported, + McpHttpUnsupported, + McpStdioUnsupported, +} + +#[derive(Clone, Default)] +pub struct ProtocolRegistry { + openapi: openapi::OpenApiAdapter, +} + +impl ProtocolRegistry { + pub fn registration(&self, kind: SourceKind) -> ProtocolRegistration { + match kind { + SourceKind::Openapi => ProtocolRegistration::OpenApi, + SourceKind::Graphql => ProtocolRegistration::GraphqlUnsupported, + SourceKind::McpHttp => ProtocolRegistration::McpHttpUnsupported, + SourceKind::McpStdio => ProtocolRegistration::McpStdioUnsupported, + } + } + + fn require_supported(&self, kind: SourceKind) -> Result<(), ProtocolError> { + match self.registration(kind) { + ProtocolRegistration::OpenApi => Ok(()), + ProtocolRegistration::GraphqlUnsupported + | ProtocolRegistration::McpHttpUnsupported + | ProtocolRegistration::McpStdioUnsupported => Err(ProtocolError::new( + ProtocolErrorCategory::Unsupported, + "unsupported_source_kind", + "This source protocol is not supported yet.", + )), + } + } + + pub fn prepare_invocation( + &self, + lease: InvocationLease, + arguments: &Value, + ) -> Result { + self.require_supported(lease.source_kind())?; + let execution = match lease.source_kind() { + SourceKind::Openapi => { + let binding = lease.binding().openapi().ok_or_else(|| { + ProtocolError::corrupt( + "source_binding_mismatch", + "The stored tool binding does not match its source protocol.", + ) + })?; + PreparedProtocolExecution::OpenApi(self.openapi.prepare_invocation( + binding, + lease.source_configuration(), + lease.credential(), + arguments, + )?) + } + SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + return Err(ProtocolError::corrupt( + "source_binding_mismatch", + "The stored tool binding does not match its source protocol.", + )); + } + }; + Ok(PreparedProtocolInvocation { lease, execution }) + } + + pub async fn execute_invocation( + &self, + prepared: PreparedProtocolInvocation, + ) -> Result { + let PreparedProtocolInvocation { lease, execution } = prepared; + let response = match execution { + PreparedProtocolExecution::OpenApi(prepared) => { + self.openapi.execute_invocation(prepared).await + } + }; + drop(lease); + response + } +} + +#[derive(Clone)] +pub struct SourceService { + catalog: CatalogStore, + registry: ProtocolRegistry, +} + +impl SourceService { + pub fn new(catalog: CatalogStore) -> Self { + Self { + catalog, + registry: ProtocolRegistry::default(), + } + } + + pub fn registry(&self) -> &ProtocolRegistry { + &self.registry + } + + pub async fn preview_openapi( + &self, + spec: &OpenApiSpecInput, + allow_private_network: bool, + ) -> Result { + self.registry + .openapi + .preview(spec, allow_private_network) + .await + } + + pub async fn create( + &self, + kind: SourceKind, + protocol: Value, + audit: AuditContext<'_>, + ) -> Result { + self.registry.require_supported(kind)?; + match kind { + SourceKind::Openapi => { + let input = decode_protocol_input(protocol)?; + self.registry + .openapi + .create_source(&self.catalog, input, audit) + .await + } + SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + unreachable!("unsupported protocols return before create dispatch") + } + } + } + + pub async fn refresh( + &self, + source_id: &str, + audit: AuditContext<'_>, + ) -> Result { + let source = self.source(source_id).await?; + self.registry.require_supported(source.kind)?; + match source.kind { + SourceKind::Openapi => { + self.registry + .openapi + .refresh_source(&self.catalog, source, audit) + .await + } + SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + unreachable!("unsupported protocols return before refresh dispatch") + } + } + } + + pub async fn credential_metadata( + &self, + source_id: &str, + ) -> Result { + let source = self.source(source_id).await?; + self.registry.require_supported(source.kind)?; + match source.kind { + SourceKind::Openapi => { + self.registry + .openapi + .credential_metadata(&self.catalog, &source.id) + .await + } + SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + unreachable!("unsupported protocols return before credential dispatch") + } + } + } + + pub async fn replace_credentials( + &self, + source_id: &str, + expected_revision: i64, + credential: Value, + audit: AuditContext<'_>, + ) -> Result { + let source = self.source(source_id).await?; + self.registry.require_supported(source.kind)?; + match source.kind { + SourceKind::Openapi => { + let credential = decode_protocol_input(credential)?; + self.registry + .openapi + .replace_credentials( + &self.catalog, + &source.id, + expected_revision, + credential, + audit, + ) + .await + } + SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + unreachable!("unsupported protocols return before credential dispatch") + } + } + } + + pub async fn clear_credentials( + &self, + source_id: &str, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + let source = self.source(source_id).await?; + self.registry.require_supported(source.kind)?; + match source.kind { + SourceKind::Openapi => { + self.registry + .openapi + .clear_credentials(&self.catalog, &source.id, expected_revision, audit) + .await + } + SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + unreachable!("unsupported protocols return before credential dispatch") + } + } + } + + async fn source(&self, source_id: &str) -> Result { + self.catalog + .source(source_id) + .await + .map_err(protocol_catalog_error) + } +} + +fn decode_protocol_input(value: Value) -> Result { + serde_json::from_value(value).map_err(|_| { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_json", + "The request body must be valid JSON with the expected fields.", + ) + }) +} + +pub(crate) fn protocol_catalog_error(error: CatalogError) -> ProtocolError { + match error { + CatalogError::Validation { code, message } => { + ProtocolError::new(ProtocolErrorCategory::InvalidInput, code, message) + } + CatalogError::NotFound { entity: "source" } => ProtocolError::new( + ProtocolErrorCategory::NotFound, + "source_not_found", + "The requested source does not exist.", + ), + CatalogError::NotFound { .. } | CatalogError::ToolNotFound { .. } => ProtocolError::new( + ProtocolErrorCategory::NotFound, + "tool_not_found", + "The requested tool does not exist.", + ), + CatalogError::ToolDisabled { .. } => ProtocolError::new( + ProtocolErrorCategory::Conflict, + "tool_disabled", + "The requested tool is disabled.", + ), + CatalogError::RevisionConflict { .. } => ProtocolError::new( + ProtocolErrorCategory::Conflict, + "revision_conflict", + "The source changed. Refresh and retry the update.", + ), + CatalogError::CorruptData(_) => ProtocolError::corrupt( + "corrupt_protocol_data", + "The stored protocol data is invalid.", + ), + CatalogError::Database(_) | CatalogError::Crypto(_) | CatalogError::Json(_) => { + ProtocolError::new( + ProtocolErrorCategory::Internal, + "internal_error", + "The protocol operation could not be completed.", + ) + } + } +} + +pub(crate) fn protocol_outbound_error(error: OutboundError) -> ProtocolError { + let category = match error { + OutboundError::Connection + | OutboundError::DnsResolution + | OutboundError::Request + | OutboundError::Timeout + | OutboundError::UpstreamStatus { .. } => ProtocolErrorCategory::Upstream, + _ => ProtocolErrorCategory::InvalidInput, + }; + ProtocolError::new( + category, + error.code(), + "The upstream request could not be completed safely.", + ) +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use serde_json::json; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::RwLock, + time::timeout, + }; + + use super::{ + ProtocolErrorCategory, ProtocolRegistration, ProtocolRegistry, decode_protocol_input, + }; + use crate::catalog::{ + CredentialPayload, InvocationLease, InvocationLookup, InvocationRevisionToken, + ModeProvenance, SourceKind, StoredCredential, ToolBinding, ToolMode, + }; + use crate::openapi::{OpenApiBinding, OpenApiCredentialSet, OpenApiSecurityAlternative}; + use crate::protocols::openapi::CreateOpenApiSource; + + #[test] + fn registry_has_one_supported_protocol_and_explicit_extension_slots() { + let registry = ProtocolRegistry::default(); + assert_eq!( + registry.registration(SourceKind::Openapi), + ProtocolRegistration::OpenApi + ); + assert_eq!( + registry.registration(SourceKind::Graphql), + ProtocolRegistration::GraphqlUnsupported + ); + assert_eq!( + registry.registration(SourceKind::McpHttp), + ProtocolRegistration::McpHttpUnsupported + ); + assert_eq!( + registry.registration(SourceKind::McpStdio), + ProtocolRegistration::McpStdioUnsupported + ); + } + + #[test] + fn malformed_create_and_credential_values_are_invalid_json() { + let create_error = decode_protocol_input::(json!({ + "displayName": "Example", + "spec": { "type": "inline", "content": 42 } + })) + .expect_err("malformed create input is rejected"); + assert_eq!(create_error.code, "invalid_json"); + assert_eq!(create_error.category, ProtocolErrorCategory::InvalidInput); + + let credential_error = decode_protocol_input::(json!({ + "schemes": { "token": { "type": "bearer", "token": 42 } } + })) + .expect_err("malformed credential input is rejected"); + assert_eq!(credential_error.code, "invalid_json"); + assert_eq!( + credential_error.category, + ProtocolErrorCategory::InvalidInput + ); + } + + #[tokio::test] + async fn preparation_has_no_network_and_the_lease_lives_through_execution() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let server_url = format!( + "http://{}", + listener.local_addr().expect("test listener has an address") + ); + let mutation_gate = Arc::new(RwLock::new(())); + let guard = mutation_gate.clone().read_owned().await; + let input_schema = json!({ "type": "object" }); + let lease = InvocationLease { + lookup: InvocationLookup { + tool_id: "tool".to_owned(), + source_id: "source".to_owned(), + source_display_name: "Source".to_owned(), + tool_display_name: "Tool".to_owned(), + callable_path: "source.tool".to_owned(), + sandbox_path: "tools.source.tool".to_owned(), + effective_mode: ToolMode::Enabled, + mode_provenance: ModeProvenance::Intrinsic, + requires_approval: false, + }, + revisions: InvocationRevisionToken { + source_id: "source".to_owned(), + tool_id: "tool".to_owned(), + source_revision: 0, + catalog_revision: 0, + tool_revision: 0, + binding_revision: 0, + credential_revision: Some(0), + }, + source_kind: SourceKind::Openapi, + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: "/execute".to_owned(), + server_url, + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + input_schema: input_schema.clone(), + input_validator: jsonschema::validator_for(&input_schema) + .expect("test input schema compiles"), + source_configuration: json!({ + "spec": { "type": "inline" }, + "allowPrivateNetwork": true + }) + .as_object() + .expect("test source configuration is an object") + .clone(), + credential: Some(StoredCredential { + revision: 0, + credential: CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { "schemes": {} } + }), + }, + }), + _guard: guard, + }; + let registry = ProtocolRegistry::default(); + let prepared = registry + .prepare_invocation(lease, &json!({})) + .expect("invocation preparation succeeds"); + + assert!( + timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err(), + "preparation must not connect to the upstream" + ); + assert!( + mutation_gate.try_write().is_err(), + "the prepared invocation retains its lease" + ); + + let execution = tokio::spawn(async move { registry.execute_invocation(prepared).await }); + let (mut connection, _) = timeout(Duration::from_secs(2), listener.accept()) + .await + .expect("execution connects to the upstream") + .expect("test listener accepts the connection"); + assert!( + mutation_gate.try_write().is_err(), + "the lease remains held while transport is in flight" + ); + let mut request = vec![0_u8; 4096]; + let read = timeout(Duration::from_secs(2), connection.read(&mut request)) + .await + .expect("upstream request arrives") + .expect("upstream request is readable"); + assert!( + String::from_utf8_lossy(&request[..read]).starts_with("GET /execute HTTP/1.1"), + "the prepared request is executed" + ); + connection + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}", + ) + .await + .expect("test response writes"); + + let response = execution + .await + .expect("protocol execution task completes") + .expect("protocol execution succeeds"); + assert!(response.ok); + assert_eq!(response.data, Some(json!({ "ok": true }))); + assert!( + mutation_gate.try_write().is_ok(), + "the lease is released after execution" + ); + } +} diff --git a/src/protocols/openapi.rs b/src/protocols/openapi.rs new file mode 100644 index 000000000..debd601d4 --- /dev/null +++ b/src/protocols/openapi.rs @@ -0,0 +1,1030 @@ +use std::collections::BTreeMap; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use reqwest::{ + Method, + header::{self, HeaderMap, HeaderName, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use url::Url; + +use super::{ + ConfiguredCredential, CredentialMetadata, ProtocolError, ProtocolErrorCategory, + ProtocolExecutionResponse, ProtocolHttpMetadata, ProtocolInvocationError, + ProtocolResponseError, protocol_catalog_error, protocol_outbound_error, +}; +use crate::{ + catalog::{ + ArtifactKind, AuditContext, CatalogSnapshot, CatalogStore, CatalogSyncResult, CreateSource, + CredentialPayload, InitialCatalogSnapshot, SourceKind, SourceRecord, StagedArtifact, + StagedTool, StagedToolBinding, StoredCredential, ToolBinding, + }, + openapi::{ + CompiledOpenApi, OpenApiBinding, OpenApiCredentialSet, OpenApiError, + OpenApiInvocationError, OpenApiOAuthFlows, OpenApiParameterLocation, + OpenApiSecurityRequirement, OpenApiSecurityScheme, build_protocol_request_with_base, + compile_document, + }, + outbound::{HardenedHttpClient, OutboundPolicy, OutboundRequest, parse_url}, +}; + +const MAX_SPEC_BYTES: usize = 16 * 1024 * 1024; +const OPENAPI_CREDENTIAL_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum OpenApiSpecInput { + Inline { content: String }, + Url { url: String }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateOpenApiSource { + pub display_name: String, + pub preferred_slug: Option, + pub description: Option, + pub spec: OpenApiSpecInput, + #[serde(default)] + pub allow_private_network: bool, + #[serde(default)] + pub credential: OpenApiCredentialSet, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiPreview { + pub title: String, + pub description: Option, + pub tool_count: usize, + pub tools: Vec, + pub security_schemes: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiPreviewTool { + pub preferred_name: String, + pub display_name: String, + pub description: Option, + pub intrinsic_mode: crate::catalog::ToolMode, + pub security: Vec>, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenApiPreviewSecurityScheme { + pub name: String, + pub credential_type: &'static str, + pub placement: Option<&'static str>, + pub supported: bool, + pub oauth_flows: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum StoredOpenApiSpecV1 { + Inline, + Url { display_url: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OpenApiSourceConfigurationV1 { + spec: StoredOpenApiSpecV1, + allow_private_network: bool, +} + +impl OpenApiSourceConfigurationV1 { + fn decode(configuration: &Map) -> Result { + let decoded: Self = + serde_json::from_value(Value::Object(configuration.clone())).map_err(|_| { + ProtocolError::corrupt( + "invalid_source_configuration", + "The stored OpenAPI source configuration is invalid.", + ) + })?; + if let StoredOpenApiSpecV1::Url { display_url } = &decoded.spec { + require_http_url(display_url).map_err(|_| { + ProtocolError::corrupt( + "invalid_source_configuration", + "The stored OpenAPI source configuration is invalid.", + ) + })?; + } + Ok(decoded) + } + + fn encode(&self) -> Result, ProtocolError> { + serde_json::to_value(self) + .map_err(internal_encoding_error)? + .as_object() + .cloned() + .ok_or_else(|| { + internal_encoding_error(serde_json::Error::io(std::io::Error::other( + "OpenAPI source configuration did not encode as an object", + ))) + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum StoredOpenApiLocatorV1 { + Inline, + Url { + url: String, + document_base_url: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoredOpenApiCredentialV1 { + locator: StoredOpenApiLocatorV1, + credentials: OpenApiCredentialSet, +} + +impl StoredOpenApiCredentialV1 { + fn decode(stored: &StoredCredential) -> Result { + if stored.credential.schema_version != OPENAPI_CREDENTIAL_SCHEMA_VERSION { + return Err(ProtocolError::corrupt( + "unsupported_credential_schema", + "The stored OpenAPI credential schema is not supported.", + )); + } + let decoded: Self = + serde_json::from_value(stored.credential.payload.clone()).map_err(|_| { + ProtocolError::corrupt( + "invalid_source_credentials", + "The stored OpenAPI credential state is invalid.", + ) + })?; + decoded.validate_stored()?; + Ok(decoded) + } + + fn validate_stored(&self) -> Result<(), ProtocolError> { + self.credentials.validate().map_err(|_| { + ProtocolError::corrupt( + "invalid_source_credentials", + "The stored OpenAPI credential state is invalid.", + ) + })?; + if let StoredOpenApiLocatorV1::Url { + url, + document_base_url, + } = &self.locator + { + require_http_url(url).map_err(|_| { + ProtocolError::corrupt( + "invalid_source_credentials", + "The stored OpenAPI credential state is invalid.", + ) + })?; + require_http_url(document_base_url).map_err(|_| { + ProtocolError::corrupt( + "invalid_source_credentials", + "The stored OpenAPI credential state is invalid.", + ) + })?; + } + Ok(()) + } + + fn decode_for_invocation(stored: &StoredCredential) -> Result { + if stored.credential.schema_version != OPENAPI_CREDENTIAL_SCHEMA_VERSION { + return Err(ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "unsupported_credential_schema", + "The stored OpenAPI credential schema is not supported.", + )); + } + Self::decode(stored) + } + + fn payload(&self) -> Result { + Ok(CredentialPayload { + schema_version: OPENAPI_CREDENTIAL_SCHEMA_VERSION, + payload: serde_json::to_value(self).map_err(internal_encoding_error)?, + }) + } +} + +pub(super) struct PreparedOpenApiInvocation { + request: OutboundRequest, + policy: OutboundPolicy, +} + +#[derive(Clone, Default)] +pub struct OpenApiAdapter; + +impl OpenApiAdapter { + pub(super) fn prepare_invocation( + &self, + binding: &OpenApiBinding, + source_configuration: &Map, + stored: Option<&StoredCredential>, + arguments: &Value, + ) -> Result { + self.plan_invocation(binding, source_configuration, stored, arguments) + } + + pub(super) async fn execute_invocation( + &self, + prepared: PreparedOpenApiInvocation, + ) -> Result { + let response = HardenedHttpClient::new(prepared.policy) + .execute(prepared.request) + .await + .map_err(ProtocolInvocationError::Outbound)?; + let succeeded = response.status.is_success(); + let data = response_data(&response.headers, &response.body); + Ok(ProtocolExecutionResponse { + ok: succeeded, + data: succeeded.then_some(data), + error: (!succeeded).then_some(ProtocolResponseError { + code: "upstream_http_error".to_owned(), + message: "The upstream API returned an error response.".to_owned(), + }), + http: Some(ProtocolHttpMetadata { + status: response.status.as_u16(), + headers: safe_response_headers(&response.headers), + truncated: false, + }), + }) + } + + pub async fn preview( + &self, + spec: &OpenApiSpecInput, + allow_private_network: bool, + ) -> Result { + let fetched = fetch_and_compile(spec, allow_private_network).await?; + Ok(preview_response(fetched.compiled)) + } + + pub async fn create_source( + &self, + catalog: &CatalogStore, + input: CreateOpenApiSource, + audit: AuditContext<'_>, + ) -> Result { + validate_input_credentials(&input.credential)?; + let fetched = fetch_and_compile(&input.spec, input.allow_private_network).await?; + let configuration = source_configuration(&input.spec, input.allow_private_network)?; + let preferred_slug = input + .preferred_slug + .unwrap_or_else(|| input.display_name.clone()); + let credential = StoredOpenApiCredentialV1 { + locator: fetched.locator, + credentials: input.credential, + }; + let snapshot = initial_catalog_snapshot(&fetched.compiled); + let bindings = staged_bindings(&fetched.compiled); + let (source, _) = catalog + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug, + display_name: input.display_name, + description: input.description, + configuration, + }, + &credential.payload()?, + snapshot, + bindings, + audit, + ) + .await + .map_err(protocol_catalog_error)?; + Ok(source) + } + + pub async fn refresh_source( + &self, + catalog: &CatalogStore, + source: SourceRecord, + audit: AuditContext<'_>, + ) -> Result { + if source.kind != SourceKind::Openapi { + return Err(ProtocolError::corrupt( + "source_protocol_mismatch", + "The stored source does not match the OpenAPI protocol.", + )); + } + let configuration = OpenApiSourceConfigurationV1::decode(&source.configuration)?; + let stored = required_stored_credential(catalog, &source.id).await?; + let credential = StoredOpenApiCredentialV1::decode(&stored)?; + let fetched = match &credential.locator { + StoredOpenApiLocatorV1::Inline => { + let document = sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ? \ + AND artifact_kind = 'openapi_document' AND stable_key = 'document'", + ) + .bind(&source.id) + .fetch_optional(catalog.pool()) + .await + .map_err(|_| internal_storage_error())? + .ok_or_else(|| { + ProtocolError::new( + ProtocolErrorCategory::Conflict, + "source_artifact_missing", + "The source has no OpenAPI document to refresh.", + ) + })?; + FetchedSpec { + compiled: compile_bytes(document.into_bytes()).await?, + locator: StoredOpenApiLocatorV1::Inline, + } + } + StoredOpenApiLocatorV1::Url { url, .. } => { + fetch_url(url, configuration.allow_private_network).await? + } + }; + let snapshot = catalog_snapshot(&fetched.compiled, source.revision, stored.revision); + catalog + .sync_catalog_with_bindings( + &source.id, + snapshot, + staged_bindings(&fetched.compiled), + audit, + ) + .await + .map_err(protocol_catalog_error) + } + + pub async fn credential_metadata( + &self, + catalog: &CatalogStore, + source_id: &str, + ) -> Result { + let stored = required_stored_credential(catalog, source_id).await?; + let credential = StoredOpenApiCredentialV1::decode(&stored)?; + Ok(credential_metadata(stored.revision, credential)) + } + + pub async fn replace_credentials( + &self, + catalog: &CatalogStore, + source_id: &str, + expected_revision: i64, + credential_set: OpenApiCredentialSet, + audit: AuditContext<'_>, + ) -> Result { + validate_input_credentials(&credential_set)?; + let stored = required_stored_credential(catalog, source_id).await?; + if expected_revision != stored.revision { + return Err(revision_conflict()); + } + let mut credential = StoredOpenApiCredentialV1::decode(&stored)?; + credential.credentials = credential_set; + catalog + .put_credential( + source_id, + &credential.payload()?, + Some(stored.revision), + audit, + ) + .await + .map_err(protocol_catalog_error)?; + self.credential_metadata(catalog, source_id).await + } + + pub async fn clear_credentials( + &self, + catalog: &CatalogStore, + source_id: &str, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + let stored = required_stored_credential(catalog, source_id).await?; + if expected_revision != stored.revision { + return Err(revision_conflict()); + } + let mut credential = StoredOpenApiCredentialV1::decode(&stored)?; + credential.credentials = OpenApiCredentialSet::default(); + catalog + .put_credential( + source_id, + &credential.payload()?, + Some(stored.revision), + audit, + ) + .await + .map_err(protocol_catalog_error)?; + self.credential_metadata(catalog, source_id).await + } + + fn plan_invocation( + &self, + binding: &OpenApiBinding, + source_configuration: &Map, + stored: Option<&StoredCredential>, + arguments: &Value, + ) -> Result { + if binding.version != 1 { + return Err(ProtocolError::corrupt( + "unsupported_binding_schema", + "The stored OpenAPI tool binding schema is not supported.", + )); + } + let configuration = OpenApiSourceConfigurationV1::decode(source_configuration)?; + let stored = stored.ok_or_else(|| { + ProtocolError::corrupt( + "source_credentials_missing", + "The source credential state is missing.", + ) + })?; + let credential = StoredOpenApiCredentialV1::decode_for_invocation(stored)?; + let document_base_url = match &credential.locator { + StoredOpenApiLocatorV1::Inline => None, + StoredOpenApiLocatorV1::Url { + document_base_url, .. + } => Some(require_http_url(document_base_url).map_err(|_| { + ProtocolError::corrupt( + "invalid_source_credentials", + "The stored OpenAPI credential state is invalid.", + ) + })?), + }; + let protocol_request = build_protocol_request_with_base( + binding, + arguments, + &credential.credentials, + document_base_url.as_ref(), + ) + .map_err(invocation_error)?; + let method = Method::from_bytes(protocol_request.method.as_bytes()).map_err(|_| { + ProtocolError::corrupt( + "invalid_tool_binding", + "The stored OpenAPI tool binding is invalid.", + ) + })?; + let mut request = OutboundRequest::new(method, protocol_request.url); + for (name, value) in protocol_request.headers { + let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| { + ProtocolError::corrupt( + "invalid_tool_binding", + "The stored OpenAPI tool binding is invalid.", + ) + })?; + let value = HeaderValue::from_str(&value).map_err(|_| { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_tool_arguments", + "The tool arguments are invalid.", + ) + })?; + request.headers.insert(name, value); + } + request.body = protocol_request.body; + Ok(PreparedOpenApiInvocation { + request, + policy: OutboundPolicy { + allow_private_networks: configuration.allow_private_network, + ..OutboundPolicy::default() + }, + }) + } +} + +fn response_data(headers: &HeaderMap, body: &[u8]) -> Value { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if (content_type.contains("/json") || content_type.contains("+json")) + && let Ok(value) = serde_json::from_slice(body) + { + value + } else if let Ok(value) = std::str::from_utf8(body) { + Value::String(value.to_owned()) + } else { + json!({ "encoding": "base64", "data": STANDARD.encode(body) }) + } +} + +fn safe_response_headers(headers: &HeaderMap) -> BTreeMap { + [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::RETRY_AFTER, + ] + .into_iter() + .filter_map(|name| { + headers + .get(&name) + .and_then(|value| value.to_str().ok()) + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect() +} + +struct FetchedSpec { + compiled: CompiledOpenApi, + locator: StoredOpenApiLocatorV1, +} + +async fn fetch_and_compile( + spec: &OpenApiSpecInput, + allow_private_network: bool, +) -> Result { + match spec { + OpenApiSpecInput::Inline { content } => { + if content.len() > MAX_SPEC_BYTES { + return Err(ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "openapi_document_too_large", + "The OpenAPI document exceeds the allowed size.", + )); + } + let compiled = compile_bytes(content.as_bytes().to_vec()).await?; + if compiled + .tools + .iter() + .any(|tool| require_http_url(&tool.binding.server_url).is_err()) + { + return Err(ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "inline_openapi_server_required", + "Inline OpenAPI documents must define an absolute HTTP or HTTPS server URL.", + )); + } + Ok(FetchedSpec { + compiled, + locator: StoredOpenApiLocatorV1::Inline, + }) + } + OpenApiSpecInput::Url { url } => fetch_url(url, allow_private_network).await, + } +} + +async fn fetch_url(url: &str, allow_private_network: bool) -> Result { + let policy = OutboundPolicy { + allow_private_networks: allow_private_network, + max_response_bytes: MAX_SPEC_BYTES, + ..OutboundPolicy::default() + }; + let url = parse_url(url, &policy).map_err(protocol_outbound_error)?; + let response = HardenedHttpClient::new(policy) + .fetch_spec(url.clone(), HeaderMap::new()) + .await + .map_err(protocol_outbound_error)?; + let mut compiled = compile_bytes(response.body).await?; + for tool in &mut compiled.tools { + tool.binding.server_url = response + .final_url + .join(&tool.binding.server_url) + .map_err(|_| { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_openapi_document", + "The OpenAPI document contains an invalid server URL.", + ) + })? + .to_string(); + } + Ok(FetchedSpec { + compiled, + locator: StoredOpenApiLocatorV1::Url { + url: url.to_string(), + document_base_url: response.final_url.to_string(), + }, + }) +} + +async fn compile_bytes(bytes: Vec) -> Result { + tokio::task::spawn_blocking(move || compile_document(&bytes)) + .await + .map_err(|_| internal_compile_error())? + .map_err(openapi_error) +} + +fn source_configuration( + spec: &OpenApiSpecInput, + allow_private_network: bool, +) -> Result, ProtocolError> { + let spec = match spec { + OpenApiSpecInput::Inline { .. } => StoredOpenApiSpecV1::Inline, + OpenApiSpecInput::Url { url } => { + let mut display = require_http_url(url).map_err(protocol_outbound_error)?; + display.set_query(None); + StoredOpenApiSpecV1::Url { + display_url: display.to_string(), + } + } + }; + OpenApiSourceConfigurationV1 { + spec, + allow_private_network, + } + .encode() +} + +fn initial_catalog_snapshot(compiled: &CompiledOpenApi) -> InitialCatalogSnapshot { + InitialCatalogSnapshot { + artifacts: staged_artifacts(compiled), + tools: staged_tools(compiled), + } +} + +fn catalog_snapshot( + compiled: &CompiledOpenApi, + source_revision: i64, + credential_revision: i64, +) -> CatalogSnapshot { + CatalogSnapshot { + expected_source_revision: source_revision, + expected_credential_revision: Some(credential_revision), + artifacts: staged_artifacts(compiled), + tools: staged_tools(compiled), + } +} + +fn staged_tools(compiled: &CompiledOpenApi) -> Vec { + compiled + .tools + .iter() + .map(|tool| StagedTool { + stable_key: tool.stable_key.clone(), + preferred_name: tool.preferred_name.clone(), + display_name: tool.display_name.clone(), + description: tool.description.clone(), + input_schema: tool.input_schema.clone(), + output_schema: tool.output_schema.clone(), + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: tool.intrinsic_mode, + }) + .collect() +} + +fn staged_bindings(compiled: &CompiledOpenApi) -> Vec { + compiled + .tools + .iter() + .map(|tool| StagedToolBinding { + stable_key: tool.stable_key.clone(), + binding: ToolBinding::OpenapiV1(tool.binding.clone()), + }) + .collect() +} + +fn staged_artifacts(compiled: &CompiledOpenApi) -> Vec { + vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: compiled.document.clone(), + }] +} + +fn preview_response(compiled: CompiledOpenApi) -> OpenApiPreview { + let tool_count = compiled.tools.len(); + let mut security_schemes = BTreeMap::new(); + for tool in &compiled.tools { + for alternative in &tool.binding.security { + for requirement in &alternative.requirements { + security_schemes + .entry(requirement.scheme_name.clone()) + .or_insert_with(|| preview_security_scheme(requirement)); + } + } + } + OpenApiPreview { + title: compiled.title, + description: compiled.description, + tool_count, + tools: compiled + .tools + .into_iter() + .map(|tool| OpenApiPreviewTool { + preferred_name: tool.preferred_name, + display_name: tool.display_name, + description: tool.description, + intrinsic_mode: tool.intrinsic_mode, + security: tool + .binding + .security + .into_iter() + .map(|alternative| { + alternative + .requirements + .into_iter() + .map(|requirement| requirement.scheme_name) + .collect() + }) + .collect(), + }) + .collect(), + security_schemes: security_schemes.into_values().collect(), + } +} + +fn preview_security_scheme( + requirement: &OpenApiSecurityRequirement, +) -> OpenApiPreviewSecurityScheme { + let (credential_type, placement, supported) = match &requirement.scheme { + OpenApiSecurityScheme::ApiKey { location, .. } => ( + "api_key", + Some(match location { + OpenApiParameterLocation::Header => "header", + OpenApiParameterLocation::Query => "query", + OpenApiParameterLocation::Cookie => "cookie", + OpenApiParameterLocation::Path => "path", + }), + *location != OpenApiParameterLocation::Path, + ), + OpenApiSecurityScheme::Http { scheme, .. } if scheme == "bearer" => { + ("bearer", Some("header"), true) + } + OpenApiSecurityScheme::Http { scheme, .. } if scheme == "basic" => { + ("basic", Some("header"), true) + } + OpenApiSecurityScheme::Http { .. } => ("http", Some("header"), false), + OpenApiSecurityScheme::OAuth2 | OpenApiSecurityScheme::OpenIdConnect { .. } => { + ("manual_oauth_access_token", Some("header"), true) + } + OpenApiSecurityScheme::MutualTls => ("mutual_tls", None, false), + }; + OpenApiPreviewSecurityScheme { + name: requirement.scheme_name.clone(), + credential_type, + placement, + supported, + oauth_flows: requirement.oauth_flows.clone(), + } +} + +async fn required_stored_credential( + catalog: &CatalogStore, + source_id: &str, +) -> Result { + catalog + .credential(source_id) + .await + .map_err(protocol_catalog_error)? + .ok_or_else(|| { + ProtocolError::corrupt( + "source_credentials_missing", + "The source credential state is missing.", + ) + }) +} + +fn credential_metadata(revision: i64, stored: StoredOpenApiCredentialV1) -> CredentialMetadata { + CredentialMetadata { + revision, + configured_schemes: stored + .credentials + .schemes + .into_iter() + .map(|(name, credential)| ConfiguredCredential { + name, + credential_type: credential.credential_type(), + }) + .collect(), + } +} + +fn validate_input_credentials(credentials: &OpenApiCredentialSet) -> Result<(), ProtocolError> { + credentials.validate().map_err(|_| { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_credentials", + "The static credential configuration is invalid.", + ) + }) +} + +fn invocation_error(error: OpenApiInvocationError) -> ProtocolError { + match error { + OpenApiInvocationError::InvalidArguments | OpenApiInvocationError::InvalidArgument(_) => { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_tool_arguments", + "The tool arguments are invalid.", + ) + } + OpenApiInvocationError::MissingArgument(_) => ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "missing_tool_argument", + "A required tool argument is missing.", + ), + OpenApiInvocationError::UnsatisfiedSecurity => ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "missing_source_credentials", + "No configured credential satisfies this operation.", + ), + OpenApiInvocationError::InvalidCredential(_) => ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "unsupported_authentication", + "This operation requires an authentication method that is not configured.", + ), + OpenApiInvocationError::InvalidCredentialConfiguration => ProtocolError::corrupt( + "invalid_source_credentials", + "The stored OpenAPI credential state is invalid.", + ), + OpenApiInvocationError::InvalidHeader(_) => ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "forbidden_tool_header", + "Tool arguments cannot set a protected HTTP header.", + ), + OpenApiInvocationError::InvalidUrl => ProtocolError::corrupt( + "invalid_openapi_server", + "The OpenAPI operation has no usable server URL.", + ), + } +} + +fn openapi_error(error: OpenApiError) -> ProtocolError { + let code = match &error { + OpenApiError::Parse => "invalid_openapi_document", + OpenApiError::UnsupportedVersion => "unsupported_openapi_version", + OpenApiError::ExternalReference(_) => "external_reference_unsupported", + OpenApiError::ReferenceNotFound(_) => "openapi_reference_not_found", + OpenApiError::ReferenceCycle(_) => "openapi_reference_cycle", + OpenApiError::ReferenceDepth => "openapi_reference_depth", + OpenApiError::LimitExceeded { code } => code, + OpenApiError::InvalidDocument(_) | OpenApiError::InvalidOperation { .. } => { + "invalid_openapi_document" + } + }; + ProtocolError::new(ProtocolErrorCategory::InvalidInput, code, error.to_string()) +} + +fn require_http_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| crate::outbound::OutboundError::InvalidUrl)?; + if !matches!(url.scheme(), "http" | "https") { + return Err(crate::outbound::OutboundError::UnsupportedScheme); + } + if url.host().is_none() { + return Err(crate::outbound::OutboundError::MissingHost); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(crate::outbound::OutboundError::CredentialsNotAllowed); + } + if url.fragment().is_some() { + return Err(crate::outbound::OutboundError::FragmentNotAllowed); + } + Ok(url) +} + +fn revision_conflict() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Conflict, + "revision_conflict", + "The source changed. Refresh and retry the update.", + ) +} + +fn internal_storage_error() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Internal, + "internal_error", + "The protocol operation could not be completed.", + ) +} + +fn internal_compile_error() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Internal, + "openapi_compiler_failed", + "The OpenAPI document could not be compiled.", + ) +} + +fn internal_encoding_error(_error: serde_json::Error) -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Internal, + "internal_error", + "The protocol operation could not be completed.", + ) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use reqwest::header::{ + AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderValue, RETRY_AFTER, + SET_COOKIE, + }; + use serde_json::json; + + use super::{ + OpenApiSourceConfigurationV1, StoredOpenApiCredentialV1, response_data, + safe_response_headers, + }; + use crate::{ + catalog::{CredentialPayload, StoredCredential}, + protocols::ProtocolErrorCategory, + }; + + #[test] + fn malformed_source_configuration_is_corrupt_data() { + let malformed = json!({ + "spec": { "type": "inline" }, + "allowPrivateNetwork": "yes" + }) + .as_object() + .expect("test configuration is an object") + .clone(); + let error = OpenApiSourceConfigurationV1::decode(&malformed) + .expect_err("malformed stored configuration fails closed"); + assert_eq!(error.code, "invalid_source_configuration"); + assert_eq!(error.category, ProtocolErrorCategory::CorruptData); + } + + #[test] + fn incomplete_source_configuration_is_corrupt_data() { + let incomplete = json!({ "spec": { "type": "inline" } }) + .as_object() + .expect("test configuration is an object") + .clone(); + let error = OpenApiSourceConfigurationV1::decode(&incomplete) + .expect_err("incomplete stored configuration fails closed"); + assert_eq!(error.code, "invalid_source_configuration"); + assert_eq!(error.category, ProtocolErrorCategory::CorruptData); + } + + #[test] + fn malformed_stored_credentials_are_corrupt_data() { + let stored = StoredCredential { + revision: 4, + credential: CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { + "schemes": { + "oauth": { "type": "oauth_access_token", "access_token": "" } + } + } + }), + }, + }; + let error = StoredOpenApiCredentialV1::decode(&stored) + .expect_err("invalid persisted credential values fail closed"); + assert_eq!(error.code, "invalid_source_credentials"); + assert_eq!(error.category, ProtocolErrorCategory::CorruptData); + } + + #[test] + fn protocol_response_data_preserves_json_text_and_binary_bodies() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + assert_eq!( + response_data(&headers, br#"{"ok":true}"#), + json!({ "ok": true }) + ); + + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/problem+json"), + ); + assert_eq!( + response_data(&headers, br#"{"title":"problem"}"#), + json!({ "title": "problem" }) + ); + + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + assert_eq!(response_data(&headers, b"hello"), json!("hello")); + assert_eq!( + response_data(&headers, &[0xff, 0x00]), + json!({ "encoding": "base64", "data": "/wA=" }) + ); + } + + #[test] + fn protocol_response_headers_use_the_existing_safe_allowlist() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("12")); + headers.insert(RETRY_AFTER, HeaderValue::from_static("5")); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret")); + headers.insert(SET_COOKIE, HeaderValue::from_static("session=secret")); + headers.insert("x-upstream-secret", HeaderValue::from_static("secret")); + + assert_eq!( + safe_response_headers(&headers), + BTreeMap::from([ + ("content-length".to_owned(), "12".to_owned()), + ("content-type".to_owned(), "application/json".to_owned()), + ("retry-after".to_owned(), "5".to_owned()), + ]) + ); + } +} diff --git a/src/api/request_logs.rs b/src/request_logs.rs similarity index 68% rename from src/api/request_logs.rs rename to src/request_logs.rs index 51ce67240..f44aa24ed 100644 --- a/src/api/request_logs.rs +++ b/src/request_logs.rs @@ -3,7 +3,11 @@ use std::sync::{ atomic::{AtomicU64, Ordering}, }; -use tokio::sync::mpsc::{self, error::TrySendError}; +use tokio::sync::{ + mpsc::{self, error::TrySendError}, + oneshot, +}; +use tokio::task::JoinHandle; use crate::{ catalog::{CatalogError, CatalogStore, NewRequestLog}, @@ -14,15 +18,18 @@ const DEFAULT_CAPACITY: usize = 1_024; const MAX_BATCH_SIZE: usize = 64; #[derive(Clone)] -pub(super) struct GatewayRequestLogSink { +pub(crate) struct RequestLogSink { sender: mpsc::Sender, database: Database, telemetry: Arc, + consumer: Arc>>>, + shutdown: Arc>>>, } struct QueuedRequestLog { log: NewRequestLog, _database_guard: Database, + completion: Option>, } #[derive(Default)] @@ -40,8 +47,8 @@ enum DropReason { Closed, } -impl GatewayRequestLogSink { - pub(super) fn new(database: Database, catalog: CatalogStore) -> Self { +impl RequestLogSink { + pub(crate) fn new(database: Database, catalog: CatalogStore) -> Self { Self::with_capacity(database, catalog, DEFAULT_CAPACITY) } @@ -52,19 +59,28 @@ impl GatewayRequestLogSink { ) -> Self { assert!(capacity > 0, "request-log sink capacity must be positive"); let (sender, receiver) = mpsc::channel(capacity); + let (shutdown, shutdown_requested) = oneshot::channel(); let telemetry = Arc::new(RequestLogTelemetry::default()); - tokio::spawn(consume(receiver, catalog, telemetry.clone())); + let consumer = tokio::spawn(consume( + receiver, + catalog, + telemetry.clone(), + shutdown_requested, + )); Self { sender, database, telemetry, + consumer: Arc::new(std::sync::Mutex::new(Some(consumer))), + shutdown: Arc::new(std::sync::Mutex::new(Some(shutdown))), } } - pub(super) fn try_record(&self, log: NewRequestLog) -> bool { + pub(crate) fn try_record(&self, log: NewRequestLog) -> bool { let queued = QueuedRequestLog { log, _database_guard: self.database.clone(), + completion: None, }; match self.sender.try_send(queued) { Ok(()) => true, @@ -81,6 +97,49 @@ impl GatewayRequestLogSink { } } + pub(crate) async fn record_durable(&self, log: NewRequestLog) -> bool { + let (completion, completed) = oneshot::channel(); + let queued = QueuedRequestLog { + log, + _database_guard: self.database.clone(), + completion: Some(completion), + }; + if self.sender.send(queued).await.is_err() { + return false; + } + completed.await.unwrap_or(false) + } + + pub(crate) fn abort(&self) { + if let Some(consumer) = self + .consumer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + { + consumer.abort(); + } + } + + pub(crate) async fn shutdown(&self) { + if let Some(shutdown) = self + .shutdown + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = shutdown.send(()); + } + let consumer = self + .consumer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(consumer) = consumer { + let _ = consumer.await; + } + } + #[cfg(test)] fn counters(&self) -> RequestLogSinkCounters { self.telemetry.counters() @@ -148,9 +207,26 @@ async fn consume( mut receiver: mpsc::Receiver, catalog: CatalogStore, telemetry: Arc, + mut shutdown: oneshot::Receiver<()>, ) { let mut batch = Vec::with_capacity(MAX_BATCH_SIZE); - while let Some(queued) = receiver.recv().await { + let mut draining = false; + loop { + let queued = if draining { + receiver.recv().await + } else { + tokio::select! { + queued = receiver.recv() => queued, + _ = &mut shutdown => { + receiver.close(); + draining = true; + receiver.recv().await + } + } + }; + let Some(queued) = queued else { + break; + }; batch.push(queued); while batch.len() < MAX_BATCH_SIZE { match receiver.try_recv() { @@ -163,11 +239,25 @@ async fn consume( let QueuedRequestLog { log, _database_guard, + completion, } = queued; let request_id = log.request_id.clone(); - match catalog.record_request(log).await { - Ok(()) => telemetry.record_write(), - Err(error) => telemetry.record_write_failure(&request_id, &error), + let succeeded = match catalog.record_request(log).await { + Ok(()) => { + telemetry.record_write(); + true + } + Err(error) => { + if catalog.request_log(&request_id).await.is_ok() { + true + } else { + telemetry.record_write_failure(&request_id, &error); + false + } + } + }; + if let Some(completion) = completion { + let _ = completion.send(succeeded); } } } @@ -198,7 +288,7 @@ mod tests { use tempfile::TempDir; use tokio::{sync::mpsc, time::timeout}; - use super::{GatewayRequestLogSink, RequestLogTelemetry}; + use super::{RequestLogSink, RequestLogTelemetry}; use crate::{ AppConfig, catalog::{CatalogStore, NewRequestLog, RequestOutcome, RequestSurface}, @@ -236,10 +326,12 @@ mod tests { let (_directory, database, _catalog) = open_database().await; let (sender, _receiver) = mpsc::channel(1); let telemetry = Arc::new(RequestLogTelemetry::default()); - let sink = GatewayRequestLogSink { + let sink = RequestLogSink { sender, database, telemetry, + consumer: Arc::new(std::sync::Mutex::new(None)), + shutdown: Arc::new(std::sync::Mutex::new(None)), }; assert!(sink.try_record(request_log("queued"))); @@ -252,10 +344,10 @@ mod tests { #[tokio::test] async fn consumer_recovers_after_a_failed_record_and_preserves_metadata() { let (_directory, database, catalog) = open_database().await; - let sink = GatewayRequestLogSink::with_capacity(database, catalog.clone(), 2); + let sink = RequestLogSink::with_capacity(database, catalog.clone(), 2); - assert!(sink.try_record(request_log(""))); - assert!(sink.try_record(request_log("recorded"))); + assert!(!sink.record_durable(request_log("")).await); + assert!(sink.record_durable(request_log("recorded")).await); let stored = timeout(Duration::from_secs(2), async { loop { diff --git a/src/runtime/invocation.rs b/src/runtime/invocation.rs new file mode 100644 index 000000000..6ff38f745 --- /dev/null +++ b/src/runtime/invocation.rs @@ -0,0 +1,389 @@ +use std::sync::{Arc, atomic::Ordering}; + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::Value; + +use super::{ + ExecutionCancellation, HostToolDispatcher, ToolCall as RuntimeToolCall, + ToolResult as RuntimeToolResult, +}; +use crate::{ + actor::ToolActor, + approval::ApprovalStatus, + catalog::{CatalogError, RequestSurface}, + invocation::{ + ApprovalWaitError, ToolCall, ToolCallError, ToolCallService, ToolCallSubmission, + ToolDiscoveryError, ToolResult as InvocationToolResult, + }, +}; + +#[derive(Clone, Debug)] +pub struct InvocationContext { + pub request_id: String, + pub actor: ToolActor, + pub surface: RequestSurface, + pub execution_id: String, +} + +pub struct InvocationToolDispatcher { + service: ToolCallService, + context: InvocationContext, + cleanup_completed: tokio::sync::Mutex, +} + +impl InvocationToolDispatcher { + pub fn new(service: ToolCallService, context: InvocationContext) -> Self { + Self { + service, + context, + cleanup_completed: tokio::sync::Mutex::new(false), + } + } + + pub async fn finish(&self) { + self.cancel_pending_once().await; + } + + pub(crate) fn discard_unstarted(&self) { + self.service + .clear_execution_stopping_flag(&self.context.execution_id); + } + + async fn cancel_pending_once(&self) { + let mut completed = self.cleanup_completed.lock().await; + if *completed { + return; + } + self.service + .cancel_lost_execution(&self.context.execution_id) + .await; + *completed = true; + } + + async fn dispatch_call( + &self, + call: RuntimeToolCall, + cancellation: ExecutionCancellation, + ) -> RuntimeToolResult { + if call.execution_id != self.context.execution_id { + return internal_failure("tool_correlation_failed"); + } + if cancellation.is_cancelled() { + return internal_failure("execution_cancelled"); + } + let service_call = ToolCall { + request_id: format!("{}:call:{}", self.context.request_id, call.call_id), + actor: self.context.actor.clone(), + surface: self.context.surface, + execution_id: self.context.execution_id.clone(), + call_id: call.call_id.to_string(), + worker_generation: call.worker_generation, + path: call.path.clone(), + arguments: call.arguments.clone(), + }; + if let Some(result) = self + .dispatch_builtin(&service_call, cancellation.clone()) + .await + { + return result; + } + + let call_id = service_call.call_id.clone(); + let submission = tokio::select! { + result = self.service.submit(service_call) => result, + () = cancellation.cancelled() => { + return internal_failure("execution_cancelled"); + } + }; + let submission = match submission { + Ok(submission) => submission, + Err(error) => return tool_call_failure(&error), + }; + match submission { + ToolCallSubmission::Completed(result) => invocation_result(result), + ToolCallSubmission::ApprovalRequired(approval) => { + let detail = self + .service + .wait_for_approval( + approval, + &self.context.actor, + &self.context.execution_id, + &call_id, + cancellation.cancelled(), + ) + .await; + match detail { + Ok(detail) => approval_result(detail.record.status, detail.result), + Err(ApprovalWaitError::Canceled) => internal_failure("execution_cancelled"), + Err(ApprovalWaitError::Approval(_)) => internal_failure("approval_wait_failed"), + } + } + } + } + + async fn dispatch_builtin( + &self, + call: &ToolCall, + cancellation: ExecutionCancellation, + ) -> Option { + let result = match call.path.as_str() { + "search" => { + let arguments: SearchArguments = + match serde_json::from_value(call.arguments.clone()) { + Ok(arguments) => arguments, + Err(_) => { + return Some(failure( + "invalid_tool_arguments", + "Search arguments are invalid.", + )); + } + }; + if arguments.query.chars().count() > 256 { + return Some(failure( + "invalid_query", + "Search queries may contain at most 256 characters.", + )); + } + tokio::select! { + result = self.service.discover_search( + call, + arguments.query, + arguments.namespace, + arguments.limit.unwrap_or(12), + arguments.offset.unwrap_or(0), + ) => result.map(|page| serde_json::to_value(page).expect("discovery pages serialize")), + () = cancellation.cancelled() => { + return Some(internal_failure("execution_cancelled")); + } + } + } + "describe" => { + let arguments: PathArguments = match serde_json::from_value(call.arguments.clone()) + { + Ok(arguments) => arguments, + Err(_) => { + return Some(failure( + "invalid_tool_arguments", + "Describe arguments are invalid.", + )); + } + }; + tokio::select! { + result = self.service.discover_describe(call, &arguments.path) => result.map(|tool| serde_json::to_value(tool).expect("described tools serialize")), + () = cancellation.cancelled() => { + return Some(internal_failure("execution_cancelled")); + } + } + } + "sources" => tokio::select! { + result = self.service.discover_sources(call) => result, + () = cancellation.cancelled() => { + return Some(internal_failure("execution_cancelled")); + } + }, + _ => return None, + }; + Some(match result { + Ok(value) => RuntimeToolResult::Success { value }, + Err(error) => discovery_failure(&error), + }) + } +} + +fn discovery_failure(error: &ToolDiscoveryError) -> RuntimeToolResult { + match error { + ToolDiscoveryError::Busy => { + failure("search_busy", "Tool search is busy. Try again shortly.") + } + ToolDiscoveryError::Catalog(error) => catalog_failure(error), + ToolDiscoveryError::Interrupted => internal_failure("discovery_interrupted"), + } +} + +#[async_trait] +impl HostToolDispatcher for InvocationToolDispatcher { + async fn dispatch( + &self, + call: RuntimeToolCall, + cancellation: ExecutionCancellation, + ) -> RuntimeToolResult { + self.dispatch_call(call, cancellation).await + } + + async fn execution_finished(&self, execution_id: &str) { + if execution_id == self.context.execution_id { + self.cancel_pending_once().await; + } + } + + async fn execution_stopping(&self, execution_id: &str) { + if execution_id == self.context.execution_id { + self.service.mark_execution_lost(execution_id).await; + } + } + + fn cancellation_hook(&self, execution_id: &str) -> Option> { + if execution_id != self.context.execution_id { + return None; + } + let execution_stopping = self.service.execution_stopping_flag(execution_id); + Some(Arc::new(move || { + execution_stopping.store(true, Ordering::Release); + })) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SearchArguments { + query: String, + namespace: Option, + limit: Option, + offset: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathArguments { + path: String, +} + +fn approval_result(status: ApprovalStatus, result: Option) -> RuntimeToolResult { + match status { + ApprovalStatus::Succeeded | ApprovalStatus::Failed => result + .and_then(|result| serde_json::from_value::(result).ok()) + .map(invocation_result) + .unwrap_or_else(|| internal_failure("approval_result_invalid")), + ApprovalStatus::Denied => failure("approval_denied", "The tool call was denied."), + ApprovalStatus::Expired => failure("approval_expired", "The tool approval expired."), + ApprovalStatus::Canceled => failure("approval_canceled", "The tool call was canceled."), + ApprovalStatus::Stale => failure( + "approval_stale", + "The tool changed before approval completed.", + ), + ApprovalStatus::Interrupted => internal_failure("approval_interrupted"), + ApprovalStatus::Pending | ApprovalStatus::Approved | ApprovalStatus::Executing => { + internal_failure("approval_state_invalid") + } + } +} + +fn invocation_result(result: InvocationToolResult) -> RuntimeToolResult { + if result.ok { + RuntimeToolResult::Success { + value: result.data.unwrap_or(Value::Null), + } + } else if let Some(error) = result.error { + failure(error.code, error.message) + } else { + internal_failure("tool_result_invalid") + } +} + +fn tool_call_failure(error: &ToolCallError) -> RuntimeToolResult { + match error { + ToolCallError::Catalog(error) => catalog_failure(error), + ToolCallError::Adapter { code, message } => failure(*code, message.clone()), + ToolCallError::ArgumentsTooLarge => failure( + "arguments_too_large", + "Tool arguments exceed the allowed size.", + ), + ToolCallError::InvalidArguments => failure( + "invalid_tool_arguments", + "Tool arguments do not match the input schema.", + ), + ToolCallError::Stale => failure("invocation_stale", "The tool changed before invocation."), + ToolCallError::Outbound(error) => failure( + error.code(), + "The upstream request could not be completed safely.", + ), + ToolCallError::Protocol(error) => match error.category { + crate::protocols::ProtocolErrorCategory::CorruptData + | crate::protocols::ProtocolErrorCategory::Internal => internal_failure(error.code), + _ => failure(error.code, error.message.clone()), + }, + ToolCallError::ResultTooLarge => failure( + "result_too_large", + "The tool result exceeds the allowed size.", + ), + ToolCallError::Approval(error) => approval_failure(error), + } +} + +fn approval_failure(error: &crate::approval::ApprovalError) -> RuntimeToolResult { + use crate::approval::ApprovalError; + + match error { + ApprovalError::Capacity { .. } => failure( + "approval_capacity", + "Too many approvals are active. Resolve an existing approval and retry.", + ), + ApprovalError::OwnerTokenInactive => failure( + "token_revoked", + "The API token that owns this execution is no longer active.", + ), + ApprovalError::Expired => failure("approval_expired", "The tool approval expired."), + ApprovalError::WorkerGenerationConflict => failure( + "approval_stale", + "The approval belongs to another execution generation.", + ), + ApprovalError::RevisionConflict { .. } | ApprovalError::DecisionConflict => failure( + "approval_conflict", + "The approval changed before the request completed.", + ), + ApprovalError::CorrelationConflict => failure( + "approval_correlation_conflict", + "The execution call was reused for a different request.", + ), + ApprovalError::CorrelationRetired => failure( + "approval_correlation_retired", + "The execution call replay is no longer available.", + ), + ApprovalError::InvalidTransition { .. } => failure( + "approval_invalid_state", + "The approval can no longer make this transition.", + ), + ApprovalError::Validation { code, message } => failure(*code, message.clone()), + ApprovalError::PayloadTooLarge { .. } => failure( + "approval_payload_too_large", + "The approval payload exceeds the allowed size.", + ), + ApprovalError::NotFound => failure("approval_not_found", "The approval was not found."), + ApprovalError::Database(_) + | ApprovalError::Crypto(_) + | ApprovalError::Json(_) + | ApprovalError::CorruptData(_) => internal_failure("approval_submit_failed"), + } +} + +fn catalog_failure(error: &CatalogError) -> RuntimeToolResult { + match error { + CatalogError::Validation { code, message } => failure(*code, message.clone()), + CatalogError::ToolDisabled { .. } => { + failure("tool_disabled", "The requested tool is disabled.") + } + CatalogError::ToolNotFound { .. } | CatalogError::NotFound { .. } => { + failure("tool_not_found", "The requested tool does not exist.") + } + CatalogError::RevisionConflict { .. } => failure( + "revision_conflict", + "The catalog changed before the request completed.", + ), + CatalogError::Database(_) + | CatalogError::Crypto(_) + | CatalogError::Json(_) + | CatalogError::CorruptData(_) => internal_failure("catalog_failed"), + } +} + +fn failure(code: impl Into, message: impl Into) -> RuntimeToolResult { + RuntimeToolResult::Failure { + code: code.into(), + message: message.into(), + } +} + +fn internal_failure(code: impl Into) -> RuntimeToolResult { + RuntimeToolResult::InternalFailure { code: code.into() } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 000000000..cdea8086e --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,322 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::Notify; + +mod parent; +mod protocol; +mod transform; +mod worker; + +mod invocation; + +pub use invocation::{InvocationContext, InvocationToolDispatcher}; +pub use parent::RuntimeManager; +pub use worker::worker_main; + +pub const MAX_SOURCE_BYTES: usize = 1024 * 1024; + +#[derive(Clone, Debug)] +pub struct ExecutionRequest { + pub execution_id: String, + pub code: String, + pub timeout: Duration, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeLimits { + pub wall_time_millis: u64, + pub heap_bytes: usize, + pub stack_bytes: usize, + pub argument_bytes: usize, + pub result_bytes: usize, + pub aggregate_bytes: usize, + pub max_calls: usize, + pub max_concurrent_calls: usize, + pub max_console_entries: usize, + pub max_console_bytes: usize, + pub max_emits: usize, + pub max_emit_bytes: usize, +} + +impl Default for RuntimeLimits { + fn default() -> Self { + Self { + wall_time_millis: 30_000, + heap_bytes: 64 * 1024 * 1024, + stack_bytes: 1024 * 1024, + argument_bytes: 8 * 1024 * 1024, + result_bytes: 8 * 1024 * 1024, + aggregate_bytes: 32 * 1024 * 1024, + max_calls: 128, + max_concurrent_calls: 16, + max_console_entries: 1000, + max_console_bytes: 256 * 1024, + max_emits: 100, + max_emit_bytes: 8 * 1024 * 1024, + } + } +} + +impl RuntimeLimits { + fn validate(&self) -> Result<(), RuntimeFailure> { + let valid = self.wall_time_millis > 0 + && self.wall_time_millis <= 300_000 + && self.heap_bytes > 0 + && self.heap_bytes <= 64 * 1024 * 1024 + && self.stack_bytes > 0 + && self.stack_bytes <= 1024 * 1024 + && self.argument_bytes > 0 + && self.argument_bytes <= 8 * 1024 * 1024 + && self.result_bytes > 0 + && self.result_bytes <= 8 * 1024 * 1024 + && self.aggregate_bytes > 0 + && self.aggregate_bytes <= 32 * 1024 * 1024 + && self.max_calls > 0 + && self.max_calls <= 128 + && self.max_concurrent_calls > 0 + && self.max_concurrent_calls <= 16 + && self.max_console_entries <= 1000 + && self.max_console_bytes <= 256 * 1024 + && self.max_emits <= 100 + && self.max_emit_bytes <= 8 * 1024 * 1024; + if valid { + Ok(()) + } else { + Err(RuntimeFailure::internal( + "runtime_limits_invalid", + "sandbox limits exceeded the supported maximum", + )) + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ConsoleLevel { + Debug, + Info, + Log, + Warn, + Error, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ConsoleEntry { + pub level: ConsoleLevel, + pub message: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ToolResult { + Success { + value: Value, + }, + Failure { + code: String, + message: String, + }, + #[doc(hidden)] + InternalFailure { + code: String, + }, +} + +#[derive(Clone, Debug)] +pub struct ToolCall { + pub execution_id: String, + pub worker_generation: u64, + pub call_id: u64, + pub path: String, + pub arguments: Value, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallRecord { + pub call_id: u64, + pub path: String, + pub result: ToolResult, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionOutput { + pub result: Value, + pub emits: Vec, + pub console: Vec, + pub tool_calls: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeFailure { + pub code: String, + pub message: String, + pub internal: bool, +} + +impl RuntimeFailure { + pub fn public(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + internal: false, + } + } + + pub(crate) fn internal(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + internal: true, + } + } +} + +impl std::fmt::Display for RuntimeFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for RuntimeFailure {} + +#[derive(Clone, Default)] +pub struct ExecutionCancellation { + inner: Arc, +} + +#[derive(Default)] +struct CancellationInner { + cancelled: AtomicBool, + notify: Notify, + hooks: std::sync::Mutex, +} + +#[derive(Default)] +struct CancellationHooks { + cancelling: bool, + pending: Vec>, +} + +impl ExecutionCancellation { + pub fn cancel(&self) { + let mut pending = { + let mut hooks = self + .inner + .hooks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.inner.cancelled.load(Ordering::Acquire) || hooks.cancelling { + return; + } + hooks.cancelling = true; + std::mem::take(&mut hooks.pending) + }; + loop { + for hook in pending.drain(..) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| hook())); + } + let mut hooks = self + .inner + .hooks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if hooks.pending.is_empty() { + self.inner.cancelled.store(true, Ordering::Release); + hooks.cancelling = false; + break; + } + pending = std::mem::take(&mut hooks.pending); + } + self.inner.notify.notify_waiters(); + } + + pub fn register_cancel_hook(&self, hook: Arc) { + let mut hooks = self + .inner + .hooks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.inner.cancelled.load(Ordering::Acquire) { + drop(hooks); + hook(); + } else { + hooks.pending.push(hook); + } + } + + pub fn is_cancelled(&self) -> bool { + self.inner.cancelled.load(Ordering::Acquire) + } + + pub async fn cancelled(&self) { + loop { + let notified = self.inner.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.is_cancelled() { + return; + } + notified.await; + } + } +} + +#[async_trait] +pub trait HostToolDispatcher: Send + Sync + 'static { + async fn dispatch(&self, call: ToolCall, cancellation: ExecutionCancellation) -> ToolResult; + + async fn execution_stopping(&self, _execution_id: &str) {} + + fn cancellation_hook(&self, _execution_id: &str) -> Option> { + None + } + + async fn execution_finished(&self, _execution_id: &str) {} +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + use super::ExecutionCancellation; + + #[test] + fn cancellation_hooks_are_reentrant_and_panic_safe() { + let cancellation = ExecutionCancellation::default(); + let first_hook_ran = Arc::new(AtomicBool::new(false)); + let nested_hook_ran = Arc::new(AtomicBool::new(false)); + let hook_cancellation = cancellation.clone(); + let hook_first = first_hook_ran.clone(); + let hook_nested = nested_hook_ran.clone(); + cancellation.register_cancel_hook(Arc::new(move || { + hook_first.store(true, Ordering::Release); + hook_cancellation.register_cancel_hook(Arc::new({ + let hook_nested = hook_nested.clone(); + move || hook_nested.store(true, Ordering::Release) + })); + hook_cancellation.cancel(); + })); + cancellation.register_cancel_hook(Arc::new(|| panic!("hook failure"))); + + cancellation.cancel(); + + assert!(cancellation.is_cancelled()); + assert!(first_hook_ran.load(Ordering::Acquire)); + assert!(nested_hook_ran.load(Ordering::Acquire)); + } +} diff --git a/src/runtime/parent.rs b/src/runtime/parent.rs new file mode 100644 index 000000000..b52334797 --- /dev/null +++ b/src/runtime/parent.rs @@ -0,0 +1,701 @@ +use std::{ + os::fd::AsRawFd, + path::{Path, PathBuf}, + process::Stdio, + sync::{ + Arc, OnceLock, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use tokio::{ + net::UnixStream, + process::{Child, Command}, + sync::{Mutex, Semaphore}, + task::JoinSet, +}; + +use super::{ + ExecutionCancellation, ExecutionOutput, ExecutionRequest, HostToolDispatcher, RuntimeFailure, + RuntimeLimits, ToolCall, ToolCallRecord, ToolResult, + protocol::{ + PROTOCOL_VERSION, ParentMessage, WorkerMessage, encode_frame, read_frame, + write_encoded_frame, + }, +}; + +static WORKER_PERMITS: OnceLock> = OnceLock::new(); + +#[cfg(target_os = "linux")] +type RlimitResource = libc::__rlimit_resource_t; +#[cfg(target_os = "macos")] +type RlimitResource = libc::c_int; + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn closefrom(low_fd: libc::c_int); +} + +#[derive(Clone, Debug)] +pub struct RuntimeManager { + executable: PathBuf, + limits: RuntimeLimits, +} + +impl RuntimeManager { + pub fn current_executable() -> Result { + let executable = std::env::current_exe().map_err(|_| { + RuntimeFailure::internal("worker_unavailable", "could not locate Executor binary") + })?; + Ok(Self::new(executable)) + } + + pub fn new(executable: impl Into) -> Self { + Self { + executable: executable.into(), + limits: RuntimeLimits::default(), + } + } + + pub fn with_limits(mut self, limits: RuntimeLimits) -> Self { + self.limits = limits; + self + } + + pub async fn execute( + &self, + request: ExecutionRequest, + dispatcher: Arc, + cancellation: ExecutionCancellation, + ) -> Result { + let actor_cancellation = ExecutionCancellation::default(); + if let Some(hook) = dispatcher.cancellation_hook(&request.execution_id) { + cancellation.register_cancel_hook(hook.clone()); + actor_cancellation.register_cancel_hook(hook); + } + let mut drop_guard = ExecutionDropGuard(Some(actor_cancellation.clone())); + let manager = self.clone(); + let actor_token = actor_cancellation.clone(); + let actor = tokio::spawn(async move { + let forwarding_token = actor_token.clone(); + let forward_cancellation = tokio::spawn(async move { + cancellation.cancelled().await; + forwarding_token.cancel(); + }); + let execution_id = request.execution_id.clone(); + let cleanup_dispatcher = dispatcher.clone(); + let result = manager + .execute_owned(request, dispatcher, actor_token) + .await; + cleanup_dispatcher.execution_finished(&execution_id).await; + forward_cancellation.abort(); + result + }); + let result = actor.await.map_err(|_| { + RuntimeFailure::internal("execution_actor_failed", "execution actor failed") + })?; + drop_guard.0 = None; + result + } + + async fn execute_owned( + &self, + request: ExecutionRequest, + dispatcher: Arc, + cancellation: ExecutionCancellation, + ) -> Result { + if request.code.len() > super::MAX_SOURCE_BYTES { + return Err(RuntimeFailure::public( + "source_too_large", + "TypeScript source exceeded 1 MiB", + )); + } + if request.timeout.is_zero() { + return Err(RuntimeFailure::public( + "invalid_timeout", + "execution timeout must be positive", + )); + } + if request.timeout > std::time::Duration::from_secs(300) { + return Err(RuntimeFailure::public( + "invalid_timeout", + "execution timeout cannot exceed five minutes", + )); + } + let mut execution_limits = self.limits.clone(); + execution_limits.wall_time_millis = request.timeout.as_millis() as u64; + execution_limits.validate()?; + let permits = WORKER_PERMITS + .get_or_init(|| Arc::new(Semaphore::new(8))) + .clone(); + if cancellation.is_cancelled() { + return Err(cancelled()); + } + let permit = permits.try_acquire_owned().map_err(|_| { + RuntimeFailure::public("runtime_busy", "all sandbox worker slots are busy") + })?; + let generation = random_generation(); + let temp_dir = PrivateTempDir::create()?; + let (stream, mut child) = spawn_worker( + &self.executable, + generation, + temp_dir.path(), + request.timeout, + )?; + let mut process_group = ProcessGroupGuard(child.id()); + let execution_id = request.execution_id.clone(); + let run = run_execution( + stream, + generation, + request.execution_id, + request.code, + execution_limits, + dispatcher.clone(), + cancellation.clone(), + ); + tokio::pin!(run); + let outcome = match tokio::time::timeout(request.timeout, &mut run).await { + Ok(result) => result, + Err(_) => { + dispatcher.execution_stopping(&execution_id).await; + cancellation.cancel(); + let _ = run.await; + Err(RuntimeFailure::public( + "execution_timeout", + "TypeScript execution timed out", + )) + } + }; + if outcome.is_err() { + dispatcher.execution_stopping(&execution_id).await; + cancellation.cancel(); + terminate_worker(&mut child).await; + process_group.disarm(); + } else { + let status = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait()) + .await + .ok() + .and_then(Result::ok); + if !status.is_some_and(|status| status.success()) { + cancellation.cancel(); + terminate_worker(&mut child).await; + process_group.disarm(); + drop(permit); + return Err(RuntimeFailure::internal( + "worker_crashed", + "sandbox worker exited unexpectedly", + )); + } + process_group.disarm(); + } + drop(permit); + outcome + } +} + +struct ExecutionDropGuard(Option); + +impl Drop for ExecutionDropGuard { + fn drop(&mut self) { + if let Some(cancellation) = self.0.take() { + cancellation.cancel(); + } + } +} + +async fn run_execution( + stream: UnixStream, + generation: u64, + execution_id: String, + code: String, + limits: RuntimeLimits, + dispatcher: Arc, + cancellation: ExecutionCancellation, +) -> Result { + let (mut reader, writer) = stream.into_split(); + let writer = Arc::new(Mutex::new(writer)); + let aggregate_bytes = Arc::new(AtomicUsize::new(0)); + write_parent_message( + &writer, + &aggregate_bytes, + &ParentMessage::Start { + version: PROTOCOL_VERSION, + generation, + code, + limits: limits.clone(), + }, + limits.aggregate_bytes, + ) + .await?; + + let call_permits = Arc::new(Semaphore::new(limits.max_concurrent_calls)); + let records = Arc::new(Mutex::new(Vec::new())); + let mut calls = JoinSet::new(); + let mut expected_call_id = 1u64; + let outcome = async { + loop { + let (message, frame_bytes) = tokio::select! { + frame = read_frame::<_, WorkerMessage>(&mut reader) => frame?, + completed = calls.join_next(), if !calls.is_empty() => { + match completed { + Some(Ok(Ok(()))) => continue, + Some(Ok(Err(error))) => return Err(error), + Some(Err(_)) | None => { + return Err(RuntimeFailure::internal( + "tool_bridge_failed", + "tool dispatch task failed", + )); + } + } + } + () = cancellation.cancelled() => return Err(cancelled()), + }; + add_aggregate_bytes(&aggregate_bytes, frame_bytes, limits.aggregate_bytes)?; + match message { + WorkerMessage::ToolCall { + version, + generation: message_generation, + call_id, + path, + arguments, + } => { + validate_header(version, message_generation, generation)?; + if call_id as usize > limits.max_calls { + return Err(RuntimeFailure::public( + "tool_call_limit_exceeded", + "execution exceeded 128 tool calls", + )); + } + if call_id != expected_call_id { + return Err(RuntimeFailure::internal( + "ipc_protocol_violation", + "worker tool call ID was invalid", + )); + } + expected_call_id += 1; + if path.is_empty() || path.len() > 512 || path.split('.').any(str::is_empty) { + return Err(RuntimeFailure::public( + "tool_path_invalid", + "tool path was invalid", + )); + } + let argument_size = serde_json::to_vec(&arguments) + .map_err(|_| { + RuntimeFailure::internal("ipc_frame_invalid", "arguments were invalid") + })? + .len(); + if argument_size > limits.argument_bytes { + return Err(RuntimeFailure::public( + "argument_too_large", + "tool arguments exceeded 8 MiB", + )); + } + let permit = tokio::select! { + permit = call_permits.clone().acquire_owned() => permit.map_err(|_| RuntimeFailure::internal("runtime_closed", "runtime is shutting down"))?, + () = cancellation.cancelled() => return Err(cancelled()), + }; + let writer = writer.clone(); + let aggregate_bytes = aggregate_bytes.clone(); + let records = records.clone(); + let dispatcher = dispatcher.clone(); + let call_cancellation = cancellation.clone(); + let call_execution_id = execution_id.clone(); + let aggregate_limit = limits.aggregate_bytes; + let result_limit = limits.result_bytes; + calls.spawn(async move { + let call = ToolCall { + execution_id: call_execution_id, + worker_generation: generation, + call_id, + path: path.clone(), + arguments, + }; + let mut result = dispatcher.dispatch(call, call_cancellation).await; + if serde_json::to_vec(&result).map_or(true, |bytes| bytes.len() > result_limit) + { + result = ToolResult::InternalFailure { + code: "tool_result_too_large".into(), + }; + } + records.lock().await.push(ToolCallRecord { + call_id, + path, + result: result.clone(), + }); + write_parent_message( + &writer, + &aggregate_bytes, + &ParentMessage::ToolResult { + version: PROTOCOL_VERSION, + generation, + call_id, + result, + }, + aggregate_limit, + ) + .await?; + drop(permit); + Ok::<_, RuntimeFailure>(()) + }); + } + WorkerMessage::Complete { + version, + generation: message_generation, + result, + emits, + console, + } => { + validate_header(version, message_generation, generation)?; + validate_output(&result, &emits, &console, &limits)?; + while let Some(call) = calls.join_next().await { + call.map_err(|_| { + RuntimeFailure::internal("tool_bridge_failed", "tool dispatch task failed") + })??; + } + let mut tool_calls = records.lock().await.clone(); + tool_calls.sort_by_key(|record| record.call_id); + return Ok(ExecutionOutput { + result, + emits, + console, + tool_calls, + }); + } + WorkerMessage::Failed { + version, + generation: message_generation, + failure, + } => { + validate_header(version, message_generation, generation)?; + calls.abort_all(); + return Err(sanitize_worker_failure(failure)); + } + } + } + } + .await; + if outcome.is_err() { + calls.abort_all(); + while calls.join_next().await.is_some() {} + } + outcome +} + +fn sanitize_worker_failure(failure: RuntimeFailure) -> RuntimeFailure { + match failure.code.as_str() { + "source_too_large" => { + RuntimeFailure::public("source_too_large", "TypeScript source exceeded 1 MiB") + } + "typescript_invalid" => { + RuntimeFailure::public("typescript_invalid", "TypeScript could not be parsed") + } + "typescript_unsupported" => RuntimeFailure::public( + "typescript_unsupported", + "TypeScript used unsupported syntax", + ), + "transformed_source_too_large" => RuntimeFailure::public( + "transformed_source_too_large", + "transformed JavaScript exceeded 1 MiB", + ), + "execution_failed" => { + RuntimeFailure::public("execution_failed", "TypeScript execution failed") + } + "result_too_large" => RuntimeFailure::public( + "result_too_large", + "execution output exceeded its size limit", + ), + "result_not_json" => RuntimeFailure::public( + "result_not_json", + "execution result was not JSON serializable", + ), + "javascript_runtime_failed" => RuntimeFailure::internal( + "javascript_runtime_failed", + "could not initialize JavaScript runtime", + ), + "tool_bridge_failed" => { + RuntimeFailure::internal("tool_bridge_failed", "tool bridge failed") + } + _ => RuntimeFailure::internal("worker_failed", "sandbox worker failed"), + } +} + +fn validate_output( + result: &serde_json::Value, + emits: &[serde_json::Value], + console: &[super::ConsoleEntry], + limits: &RuntimeLimits, +) -> Result<(), RuntimeFailure> { + let result_bytes = serde_json::to_vec(result) + .map_err(|_| RuntimeFailure::internal("ipc_frame_invalid", "worker result was invalid"))? + .len(); + if result_bytes > limits.result_bytes { + return Err(RuntimeFailure::public( + "result_too_large", + "execution result exceeded 8 MiB", + )); + } + let emit_bytes = emits.iter().try_fold(0usize, |total, emit| { + serde_json::to_vec(emit) + .ok() + .and_then(|encoded| total.checked_add(encoded.len())) + .ok_or_else(|| { + RuntimeFailure::internal("ipc_frame_invalid", "worker emits were invalid") + }) + })?; + if emits.len() > limits.max_emits || emit_bytes > limits.max_emit_bytes { + return Err(RuntimeFailure::internal( + "ipc_protocol_violation", + "worker emit limits were invalid", + )); + } + let console_bytes = console + .iter() + .map(|entry| entry.message.len()) + .sum::(); + if console.len() > limits.max_console_entries || console_bytes > limits.max_console_bytes { + return Err(RuntimeFailure::internal( + "ipc_protocol_violation", + "worker console limits were invalid", + )); + } + Ok(()) +} + +async fn write_parent_message( + writer: &Arc>, + aggregate_bytes: &AtomicUsize, + message: &ParentMessage, + aggregate_limit: usize, +) -> Result<(), RuntimeFailure> { + let payload = encode_frame(message)?; + add_aggregate_bytes(aggregate_bytes, payload.len() + 4, aggregate_limit)?; + let mut guard = writer.lock().await; + write_encoded_frame(&mut *guard, &payload).await?; + Ok(()) +} + +fn add_aggregate_bytes( + aggregate: &AtomicUsize, + bytes: usize, + limit: usize, +) -> Result<(), RuntimeFailure> { + aggregate + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(bytes) + .filter(|updated| *updated <= limit) + }) + .map(|_| ()) + .map_err(|_| RuntimeFailure::internal("ipc_limit_exceeded", "IPC byte limit exceeded")) +} + +fn validate_header( + version: u16, + message_generation: u64, + generation: u64, +) -> Result<(), RuntimeFailure> { + if version != PROTOCOL_VERSION || message_generation != generation { + return Err(RuntimeFailure::internal( + "ipc_protocol_violation", + "worker IPC version or generation was invalid", + )); + } + Ok(()) +} + +fn spawn_worker( + executable: &Path, + generation: u64, + working_directory: &Path, + timeout: std::time::Duration, +) -> Result<(UnixStream, Child), RuntimeFailure> { + let (parent, child_socket) = std::os::unix::net::UnixStream::pair().map_err(spawn_failure)?; + parent.set_nonblocking(true).map_err(spawn_failure)?; + let child_fd = child_socket.as_raw_fd(); + #[cfg(target_os = "linux")] + let expected_parent = unsafe { libc::getpid() }; + let configured_max_fd = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) }; + let max_fd = if configured_max_fd < 0 { + 1_048_576 + } else { + configured_max_fd.min(i32::MAX as libc::c_long) as i32 + }; + let cpu_seconds = cpu_limit_seconds(timeout); + let mut command = Command::new(executable); + command + .arg("sandbox-worker") + .arg("--ipc-fd") + .arg("3") + .arg("--generation") + .arg(generation.to_string()) + .env_clear() + .current_dir(working_directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.kill_on_drop(true); + unsafe { + command.pre_exec(move || { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + #[cfg(target_os = "linux")] + { + if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1 + || libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 + || libc::getppid() != expected_parent + { + return Err(std::io::Error::last_os_error()); + } + } + libc::umask(0o077); + set_limit(libc::RLIMIT_CORE, 0)?; + set_limit(libc::RLIMIT_FSIZE, 0)?; + set_limit(libc::RLIMIT_NOFILE, 16)?; + set_limit(libc::RLIMIT_STACK, 8 * 1024 * 1024)?; + set_limit(libc::RLIMIT_AS, 2 * 1024 * 1024 * 1024)?; + set_limit(libc::RLIMIT_CPU, cpu_seconds)?; + if child_fd != 3 && libc::dup2(child_fd, 3) == -1 { + return Err(std::io::Error::last_os_error()); + } + let flags = libc::fcntl(3, libc::F_GETFD); + if flags == -1 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 { + return Err(std::io::Error::last_os_error()); + } + close_extra_fds(max_fd)?; + Ok(()) + }); + } + let child = command.spawn().map_err(spawn_failure)?; + drop(child_socket); + let parent = UnixStream::from_std(parent).map_err(spawn_failure)?; + Ok((parent, child)) +} + +unsafe fn close_extra_fds(max_fd: i32) -> std::io::Result<()> { + #[cfg(target_os = "linux")] + { + if unsafe { libc::syscall(libc::SYS_close_range, 4u32, u32::MAX, 0u32) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ENOSYS) { + return Err(error); + } + } + #[cfg(target_os = "macos")] + { + unsafe { closefrom(4) }; + return Ok(()); + } + #[allow(unreachable_code)] + { + for fd in 4..max_fd { + unsafe { libc::close(fd) }; + } + Ok(()) + } +} + +unsafe fn set_limit(resource: RlimitResource, value: u64) -> std::io::Result<()> { + let limit = libc::rlimit { + rlim_cur: value as libc::rlim_t, + rlim_max: value as libc::rlim_t, + }; + if unsafe { libc::setrlimit(resource, &limit) } == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +async fn terminate_worker(child: &mut Child) { + if let Some(id) = child.id() { + unsafe { + libc::killpg(id as i32, libc::SIGKILL); + } + } + let _ = child.kill().await; + let _ = child.wait().await; +} + +fn random_generation() -> u64 { + let bytes = uuid::Uuid::new_v4().into_bytes(); + u64::from_le_bytes(bytes[..8].try_into().expect("UUID has eight bytes")) + .rem_euclid(i64::MAX as u64) + + 1 +} + +fn cpu_limit_seconds(timeout: std::time::Duration) -> u64 { + timeout + .as_secs() + .saturating_add(u64::from(timeout.subsec_nanos() != 0)) + .max(1) +} + +fn spawn_failure(_: std::io::Error) -> RuntimeFailure { + RuntimeFailure::internal("worker_unavailable", "could not start sandbox worker") +} + +fn cancelled() -> RuntimeFailure { + RuntimeFailure::public("execution_cancelled", "TypeScript execution was cancelled") +} + +struct PrivateTempDir(PathBuf); + +impl PrivateTempDir { + fn create() -> Result { + use std::os::unix::fs::DirBuilderExt; + + let path = std::env::temp_dir().join(format!("executor-sandbox-{}", uuid::Uuid::new_v4())); + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + builder.create(&path).map_err(|_| { + RuntimeFailure::internal("worker_unavailable", "could not create sandbox directory") + })?; + Ok(Self(path)) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for PrivateTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +struct ProcessGroupGuard(Option); + +impl ProcessGroupGuard { + fn disarm(&mut self) { + self.0 = None; + } +} + +impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + if let Some(pid) = self.0 { + unsafe { + libc::killpg(pid as i32, libc::SIGKILL); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::cpu_limit_seconds; + + #[test] + fn cpu_limit_rounds_fractional_seconds_up_without_padding_exact_seconds() { + assert_eq!(cpu_limit_seconds(Duration::from_millis(1)), 1); + assert_eq!(cpu_limit_seconds(Duration::from_secs(1)), 1); + assert_eq!(cpu_limit_seconds(Duration::from_millis(1_001)), 2); + assert_eq!(cpu_limit_seconds(Duration::from_secs(300)), 300); + } +} diff --git a/src/runtime/protocol.rs b/src/runtime/protocol.rs new file mode 100644 index 000000000..bb2c96dba --- /dev/null +++ b/src/runtime/protocol.rs @@ -0,0 +1,252 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use super::{ConsoleEntry, RuntimeFailure, RuntimeLimits, ToolResult}; + +pub(crate) const PROTOCOL_VERSION: u16 = 1; +pub(crate) const FRAME_LIMIT: usize = 18 * 1024 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum ParentMessage { + Start { + version: u16, + generation: u64, + code: String, + limits: RuntimeLimits, + }, + ToolResult { + version: u16, + generation: u64, + call_id: u64, + result: ToolResult, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum WorkerMessage { + ToolCall { + version: u16, + generation: u64, + call_id: u64, + path: String, + arguments: Value, + }, + Complete { + version: u16, + generation: u64, + result: Value, + emits: Vec, + console: Vec, + }, + Failed { + version: u16, + generation: u64, + failure: RuntimeFailure, + }, +} + +pub(crate) fn encode_frame(value: &T) -> Result, RuntimeFailure> { + let payload = serde_json::to_vec(value) + .map_err(|_| RuntimeFailure::internal("ipc_encode_failed", "could not encode IPC frame"))?; + if payload.len() > FRAME_LIMIT { + return Err(RuntimeFailure::internal( + "ipc_frame_too_large", + "IPC frame exceeded its size limit", + )); + } + Ok(payload) +} + +pub(crate) async fn write_encoded_frame( + writer: &mut W, + payload: &[u8], +) -> Result +where + W: AsyncWrite + Unpin, +{ + writer + .write_u32(payload.len() as u32) + .await + .map_err(io_failure)?; + writer.write_all(payload).await.map_err(io_failure)?; + writer.flush().await.map_err(io_failure)?; + Ok(payload.len() + 4) +} + +pub(crate) async fn read_frame(reader: &mut R) -> Result<(T, usize), RuntimeFailure> +where + R: AsyncRead + Unpin, + T: for<'de> Deserialize<'de>, +{ + let length = reader.read_u32().await.map_err(io_failure)? as usize; + if length == 0 || length > FRAME_LIMIT { + return Err(RuntimeFailure::internal( + "ipc_frame_invalid", + "IPC frame length was invalid", + )); + } + let mut payload = vec![0; length]; + reader.read_exact(&mut payload).await.map_err(io_failure)?; + validate_json_structure(&payload)?; + let value = serde_json::from_slice(&payload).map_err(|_| { + RuntimeFailure::internal("ipc_frame_invalid", "IPC frame schema was invalid") + })?; + Ok((value, length + 4)) +} + +fn validate_json_structure(payload: &[u8]) -> Result<(), RuntimeFailure> { + const MAX_JSON_NODES: usize = 250_000; + const MAX_JSON_DEPTH: usize = 128; + + let mut in_string = false; + let mut escaped = false; + let mut depth = 0usize; + let mut nodes = 1usize; + for byte in payload { + if in_string { + if escaped { + escaped = false; + } else if *byte == b'\\' { + escaped = true; + } else if *byte == b'"' { + in_string = false; + } + continue; + } + match *byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth += 1; + nodes += 1; + if depth > MAX_JSON_DEPTH { + return Err(invalid_structure()); + } + } + b'}' | b']' => { + depth = depth.checked_sub(1).ok_or_else(invalid_structure)?; + } + b',' => nodes += 1, + _ => {} + } + if nodes > MAX_JSON_NODES { + return Err(invalid_structure()); + } + } + if in_string || depth != 0 { + return Err(invalid_structure()); + } + Ok(()) +} + +fn invalid_structure() -> RuntimeFailure { + RuntimeFailure::internal("ipc_frame_invalid", "IPC JSON structure was invalid") +} + +fn io_failure(_: std::io::Error) -> RuntimeFailure { + RuntimeFailure::internal("worker_disconnected", "sandbox worker disconnected") +} + +#[cfg(test)] +mod tests { + use tokio::io::AsyncWriteExt; + + use super::*; + + #[tokio::test] + async fn rejects_oversized_length_before_reading_payload() { + let (mut writer, mut reader) = tokio::io::duplex(16); + writer + .write_u32((FRAME_LIMIT + 1) as u32) + .await + .expect("length should write"); + let error = read_frame::<_, ParentMessage>(&mut reader) + .await + .expect_err("oversized frame must fail"); + assert_eq!(error.code, "ipc_frame_invalid"); + } + + #[tokio::test] + async fn rejects_partial_and_unknown_field_frames() { + let (mut writer, mut reader) = tokio::io::duplex(128); + writer.write_u32(10).await.expect("length should write"); + writer.write_all(b"{}").await.expect("payload should write"); + drop(writer); + assert_eq!( + read_frame::<_, ParentMessage>(&mut reader) + .await + .expect_err("partial frame must fail") + .code, + "worker_disconnected" + ); + + let payload = + br#"{"type":"start","version":1,"generation":1,"code":"","limits":{},"host":"leak"}"#; + let (mut writer, mut reader) = tokio::io::duplex(256); + writer + .write_u32(payload.len() as u32) + .await + .expect("length should write"); + writer + .write_all(payload) + .await + .expect("payload should write"); + assert_eq!( + read_frame::<_, ParentMessage>(&mut reader) + .await + .expect_err("unknown field must fail") + .code, + "ipc_frame_invalid" + ); + } + + #[tokio::test] + async fn rejects_deeply_nested_json() { + let nested = format!("{}null{}", "[".repeat(256), "]".repeat(256)); + let payload = format!( + r#"{{"type":"tool_call","version":1,"generation":1,"call_id":1,"path":"safe.path","arguments":{nested}}}"# + ); + let (mut writer, mut reader) = tokio::io::duplex(payload.len() + 8); + writer + .write_u32(payload.len() as u32) + .await + .expect("length should write"); + writer + .write_all(payload.as_bytes()) + .await + .expect("payload should write"); + assert_eq!( + read_frame::<_, WorkerMessage>(&mut reader) + .await + .expect_err("deep JSON must fail") + .code, + "ipc_frame_invalid" + ); + } + + #[tokio::test] + async fn rejects_excessively_wide_json_before_deserialization() { + let arguments = format!("[{}]", "0,".repeat(250_001)); + let payload = format!( + r#"{{"type":"tool_call","version":1,"generation":1,"call_id":1,"path":"safe.path","arguments":{arguments}}}"# + ); + let (mut writer, mut reader) = tokio::io::duplex(payload.len() + 8); + writer + .write_u32(payload.len() as u32) + .await + .expect("length should write"); + writer + .write_all(payload.as_bytes()) + .await + .expect("payload should write"); + assert_eq!( + read_frame::<_, WorkerMessage>(&mut reader) + .await + .expect_err("wide JSON must fail") + .code, + "ipc_frame_invalid" + ); + } +} diff --git a/src/runtime/transform.rs b/src/runtime/transform.rs new file mode 100644 index 000000000..b9b0c4bcf --- /dev/null +++ b/src/runtime/transform.rs @@ -0,0 +1,336 @@ +use std::path::Path; + +use oxc::{ + allocator::Allocator, + ast::ast::{ + BindingPattern, CallExpression, Decorator, ExportDefaultDeclarationKind, Expression, + ImportDeclaration, ImportExpression, JSXElement, JSXFragment, Program, Statement, + TSEnumDeclaration, TSModuleDeclaration, + }, + ast_visit::{Visit, walk}, + codegen::Codegen, + parser::Parser, + semantic::SemanticBuilder, + span::{GetSpan, SourceType}, + transformer::{TransformOptions, Transformer}, +}; + +use super::{MAX_SOURCE_BYTES, RuntimeFailure}; + +pub(crate) fn recover_and_transform(input: &str) -> Result { + if input.len() > MAX_SOURCE_BYTES { + return Err(RuntimeFailure::public( + "source_too_large", + "TypeScript source exceeded 1 MiB", + )); + } + let recovered = first_fenced_block(input).unwrap_or(input).trim(); + let wrapped = wrap_entrypoint(recovered)?; + + let allocator = Allocator::default(); + let source_type = SourceType::ts().with_module(true); + let parsed = Parser::new(&allocator, &wrapped, source_type).parse(); + if !parsed.diagnostics.is_empty() { + return Err(RuntimeFailure::public( + "typescript_invalid", + "TypeScript could not be parsed", + )); + } + let mut program = parsed.program; + validate_program(&program)?; + let semantic = SemanticBuilder::new().with_enum_eval(true).build(&program); + if !semantic.diagnostics.is_empty() { + return Err(RuntimeFailure::public( + "typescript_invalid", + "TypeScript semantic analysis failed", + )); + } + let transformed = Transformer::new( + &allocator, + Path::new("executor-input.ts"), + &TransformOptions::default(), + ) + .build_with_scoping(semantic.semantic.into_scoping(), &mut program); + if !transformed.diagnostics.is_empty() { + return Err(RuntimeFailure::public( + "typescript_unsupported", + "TypeScript used unsupported syntax", + )); + } + let output = Codegen::new().build(&program).code; + if output.len() > MAX_SOURCE_BYTES { + return Err(RuntimeFailure::public( + "transformed_source_too_large", + "transformed JavaScript exceeded 1 MiB", + )); + } + Ok(output) +} + +fn first_fenced_block(input: &str) -> Option<&str> { + let start = input.find("```")?; + let after_ticks = &input[start + 3..]; + let body_start = after_ticks.find('\n').map_or(0, |index| index + 1); + let body = &after_ticks[body_start..]; + let end = body.find("```")?; + Some(&body[..end]) +} + +fn validate_program(program: &Program<'_>) -> Result<(), RuntimeFailure> { + let mut validator = UnsupportedSyntax::default(); + validator.visit_program(program); + match validator.unsupported { + Some(name) => Err(RuntimeFailure::public( + "typescript_unsupported", + format!("{name} are not supported"), + )), + None => Ok(()), + } +} + +#[derive(Default)] +struct UnsupportedSyntax { + unsupported: Option<&'static str>, +} + +impl<'a> Visit<'a> for UnsupportedSyntax { + fn visit_import_declaration(&mut self, _: &ImportDeclaration<'a>) { + self.unsupported.get_or_insert("imports"); + } + + fn visit_import_expression(&mut self, _: &ImportExpression<'a>) { + self.unsupported.get_or_insert("dynamic imports"); + } + + fn visit_call_expression(&mut self, expression: &CallExpression<'a>) { + if matches!(&expression.callee, Expression::Identifier(identifier) if identifier.name == "require") + { + self.unsupported.get_or_insert("module loading"); + } + walk::walk_call_expression(self, expression); + } + + fn visit_ts_enum_declaration(&mut self, _: &TSEnumDeclaration<'a>) { + self.unsupported.get_or_insert("TypeScript enums"); + } + + fn visit_ts_module_declaration(&mut self, _: &TSModuleDeclaration<'a>) { + self.unsupported.get_or_insert("TypeScript namespaces"); + } + + fn visit_decorator(&mut self, _: &Decorator<'a>) { + self.unsupported.get_or_insert("decorators"); + } + + fn visit_jsx_element(&mut self, _: &JSXElement<'a>) { + self.unsupported.get_or_insert("JSX elements"); + } + + fn visit_jsx_fragment(&mut self, _: &JSXFragment<'a>) { + self.unsupported.get_or_insert("JSX fragments"); + } +} + +fn wrap_entrypoint(source: &str) -> Result { + if source.is_empty() { + return Err(RuntimeFailure::public( + "typescript_invalid", + "TypeScript source was empty", + )); + } + let trimmed = source.trim(); + match classify_entrypoint(trimmed)? { + Entrypoint::Body => Ok(format!( + "globalThis.__executor_entry = (async () => {{ {trimmed}\n }})();" + )), + Entrypoint::Named(name) => Ok(format!( + "globalThis.__executor_entry = (async () => {{ {trimmed}; return await {name}(); }})();" + )), + Entrypoint::Expression => Ok(format!( + "globalThis.__executor_entry = (async () => {{ const entry = ({trimmed}); return await entry(); }})();" + )), + Entrypoint::Default { start, end } => { + let expression = trimmed[start..end].trim_end_matches(';').trim_end(); + Ok(format!( + "globalThis.__executor_entry = (async () => {{ const entry = ({expression}); return await entry(); }})();" + )) + } + } +} + +enum Entrypoint { + Body, + Named(String), + Expression, + Default { start: usize, end: usize }, +} + +fn classify_entrypoint(source: &str) -> Result { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::ts().with_module(true)).parse(); + if !parsed.diagnostics.is_empty() { + let expression_source = format!("({source})"); + let expression_allocator = Allocator::default(); + let expression = Parser::new( + &expression_allocator, + &expression_source, + SourceType::ts().with_module(true), + ) + .parse(); + if expression.diagnostics.is_empty() { + validate_program(&expression.program)?; + if matches!( + expression.program.body.first(), + Some(Statement::ExpressionStatement(statement)) + if is_callable_expression(&statement.expression) + ) { + return Ok(Entrypoint::Expression); + } + } + return Ok(Entrypoint::Body); + } + validate_program(&parsed.program)?; + let Some(first) = parsed.program.body.first() else { + return Ok(Entrypoint::Body); + }; + match first { + Statement::FunctionDeclaration(function) => { + Ok(function.id.as_ref().map_or(Entrypoint::Body, |id| { + Entrypoint::Named(id.name.to_string()) + })) + } + Statement::VariableDeclaration(declaration) => { + let Some(variable) = declaration.declarations.first() else { + return Ok(Entrypoint::Body); + }; + let callable = variable.init.as_ref().is_some_and(is_callable_expression); + match (&variable.id, callable) { + (BindingPattern::BindingIdentifier(identifier), true) => { + Ok(Entrypoint::Named(identifier.name.to_string())) + } + _ => Ok(Entrypoint::Body), + } + } + Statement::ExpressionStatement(statement) + if is_callable_expression(&statement.expression) => + { + Ok(Entrypoint::Expression) + } + Statement::ExportDefaultDeclaration(declaration) + if matches!( + declaration.declaration, + ExportDefaultDeclarationKind::FunctionDeclaration(_) + | ExportDefaultDeclarationKind::ArrowFunctionExpression(_) + | ExportDefaultDeclarationKind::FunctionExpression(_) + ) => + { + let span = declaration.declaration.span(); + Ok(Entrypoint::Default { + start: span.start as usize, + end: span.end as usize, + }) + } + _ => Ok(Entrypoint::Body), + } +} + +fn is_callable_expression(expression: &Expression<'_>) -> bool { + match expression { + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => true, + Expression::ParenthesizedExpression(parenthesized) => { + is_callable_expression(&parenthesized.expression) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recovers_first_fenced_block_and_removes_types() { + let output = recover_and_transform( + "before\n```ts\nconst value: number = 2; return value\n```\nafter", + ) + .expect("source should transform"); + assert!(output.contains("const value = 2")); + assert!(!output.contains(": number")); + assert!(!output.contains("after")); + } + + #[test] + fn supports_function_and_default_export_entries() { + assert!( + recover_and_transform("async function main(): Promise { return 4 }") + .expect("declaration should transform") + .contains("main()") + ); + assert!( + recover_and_transform("export default async () => 5;") + .expect("default export should transform") + .contains("entry()") + ); + assert!( + recover_and_transform("const main = async function (): Promise { return 6 }") + .expect("callable variable should transform") + .contains("main()") + ); + assert!( + recover_and_transform("function $main() { return 7 }") + .expect("dollar identifier should transform") + .contains("$main()") + ); + assert!( + recover_and_transform("const main=async function() { return 8 }") + .expect("compact callable variable should transform") + .contains("main()") + ); + assert!( + recover_and_transform("async function () { return 9 }") + .expect("anonymous function expression should transform") + .contains("entry()") + ); + } + + #[test] + fn rejects_capabilities_and_unsupported_syntax() { + for source in [ + "import x from 'x'", + "return import('x')", + "enum X { A }", + "@sealed class X {}", + "return
", + ] { + let code = recover_and_transform(source) + .expect_err("source must be rejected") + .code; + assert!( + matches!( + code.as_str(), + "typescript_unsupported" | "typescript_invalid" + ), + "unexpected rejection code for {source}: {code}" + ); + } + } + + #[test] + fn unsupported_markers_inside_strings_and_comments_are_data() { + recover_and_transform( + r#"// import is documentation + const email = "person@example.com"; + return { email, note: "enum import require(" };"#, + ) + .expect("string and comment contents should not be syntax"); + recover_and_transform("return /@/.test('x');") + .expect("regular expression contents should remain data"); + } + + #[test] + fn rejects_unsupported_syntax_nested_in_templates_without_panicking() { + let error = recover_and_transform("return `${(() => { enum X { A }; return X.A })()}`;") + .expect_err("nested enum must be rejected"); + assert_eq!(error.code, "typescript_unsupported"); + } +} diff --git a/src/runtime/worker.rs b/src/runtime/worker.rs new file mode 100644 index 000000000..ac896bdc6 --- /dev/null +++ b/src/runtime/worker.rs @@ -0,0 +1,426 @@ +use std::{ + collections::HashMap, + os::fd::OwnedFd, + sync::{ + Arc, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use rquickjs::{ + AsyncContext, AsyncRuntime, CatchResultExt, Promise, Value, + function::{Async, Func}, +}; +use serde::Deserialize; +use serde_json::Value as JsonValue; +use tokio::{ + net::UnixStream, + sync::{Mutex, mpsc, oneshot}, +}; + +use super::{ + ConsoleEntry, RuntimeFailure, RuntimeLimits, ToolResult, + protocol::{ + PROTOCOL_VERSION, ParentMessage, WorkerMessage, encode_frame, read_frame, + write_encoded_frame, + }, + transform::recover_and_transform, +}; + +type PendingCalls = Arc>>>; + +/// Runs the internal sandbox worker over an exclusively owned Unix stream. +#[doc(hidden)] +pub async fn worker_main(ipc_fd: OwnedFd, expected_generation: u64) -> Result<(), RuntimeFailure> { + let std_stream = std::os::unix::net::UnixStream::from(ipc_fd); + std_stream.set_nonblocking(true).map_err(|_| { + RuntimeFailure::internal("worker_ipc_failed", "could not configure worker IPC") + })?; + let stream = UnixStream::from_std(std_stream) + .map_err(|_| RuntimeFailure::internal("worker_ipc_failed", "could not open worker IPC"))?; + let (mut reader, mut writer) = stream.into_split(); + let (start, initial_bytes) = read_frame::<_, ParentMessage>(&mut reader).await?; + let (generation, code, limits) = match start { + ParentMessage::Start { + version, + generation, + code, + limits, + } if version == PROTOCOL_VERSION && generation == expected_generation => { + (generation, code, limits) + } + _ => { + return Err(RuntimeFailure::internal( + "ipc_protocol_violation", + "worker start frame was invalid", + )); + } + }; + limits.validate()?; + + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(32); + let aggregate_bytes = Arc::new(AtomicUsize::new(initial_bytes)); + let writer_aggregate = aggregate_bytes.clone(); + let aggregate_limit = limits.aggregate_bytes; + let writer_task = tokio::spawn(async move { + while let Some(message) = outgoing_rx.recv().await { + let payload = encode_frame(&message)?; + let bytes = payload.len() + 4; + if writer_aggregate + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(bytes) + .filter(|updated| *updated <= aggregate_limit) + }) + .is_err() + { + return Err(RuntimeFailure::internal( + "ipc_limit_exceeded", + "IPC byte limit exceeded", + )); + } + write_encoded_frame(&mut writer, &payload).await?; + } + Ok::<_, RuntimeFailure>(()) + }); + + let pending: PendingCalls = Arc::new(Mutex::new(HashMap::new())); + let pending_reader = pending.clone(); + let reader_aggregate = aggregate_bytes; + let mut reader_task = tokio::spawn(async move { + loop { + let (message, bytes) = read_frame::<_, ParentMessage>(&mut reader).await?; + if reader_aggregate + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(bytes) + .filter(|updated| *updated <= aggregate_limit) + }) + .is_err() + { + return Err(RuntimeFailure::internal( + "ipc_limit_exceeded", + "IPC byte limit exceeded", + )); + } + match message { + ParentMessage::ToolResult { + version, + generation: message_generation, + call_id, + result, + } if version == PROTOCOL_VERSION && message_generation == generation => { + let sender = pending_reader + .lock() + .await + .remove(&call_id) + .ok_or_else(|| { + RuntimeFailure::internal( + "ipc_protocol_violation", + "tool result had an unknown or duplicate call ID", + ) + })?; + let _ = sender.send(result); + } + _ => { + return Err(RuntimeFailure::internal( + "ipc_protocol_violation", + "parent IPC message was invalid", + )); + } + } + } + #[allow(unreachable_code)] + Ok::<(), RuntimeFailure>(()) + }); + + let execution = tokio::select! { + execution = execute_javascript( + code, + limits.clone(), + generation, + outgoing_tx.clone(), + pending, + ) => execution, + reader = &mut reader_task => { + return Err(match reader { + Ok(Err(failure)) => failure, + Ok(Ok(())) => RuntimeFailure::internal("worker_disconnected", "sandbox parent disconnected"), + Err(_) => RuntimeFailure::internal("worker_failed", "IPC reader failed"), + }); + } + }; + let final_message = match execution { + Ok((result, emits, console)) => WorkerMessage::Complete { + version: PROTOCOL_VERSION, + generation, + result, + emits, + console, + }, + Err(failure) => WorkerMessage::Failed { + version: PROTOCOL_VERSION, + generation, + failure, + }, + }; + outgoing_tx.send(final_message).await.map_err(|_| { + RuntimeFailure::internal("worker_disconnected", "sandbox parent disconnected") + })?; + drop(outgoing_tx); + reader_task.abort(); + writer_task + .await + .map_err(|_| RuntimeFailure::internal("worker_failed", "IPC writer failed"))??; + Ok(()) +} + +async fn execute_javascript( + source: String, + limits: RuntimeLimits, + generation: u64, + outgoing: mpsc::Sender, + pending: PendingCalls, +) -> Result<(JsonValue, Vec, Vec), RuntimeFailure> { + let transformed = recover_and_transform(&source)?; + let runtime = AsyncRuntime::new().map_err(js_internal)?; + runtime.set_memory_limit(limits.heap_bytes).await; + runtime.set_max_stack_size(limits.stack_bytes).await; + let deadline = Instant::now() + Duration::from_millis(limits.wall_time_millis); + runtime + .set_interrupt_handler(Some(Box::new(move || Instant::now() >= deadline))) + .await; + let context = AsyncContext::full(&runtime).await.map_err(js_internal)?; + let next_call_id = Arc::new(AtomicU64::new(1)); + let issue_lock = Arc::new(Mutex::new(())); + + let collected = context + .async_with(async |ctx| { + let sender = outgoing.clone(); + let pending_calls = pending.clone(); + let call_ids = next_call_id.clone(); + let issue_calls = issue_lock.clone(); + let argument_limit = limits.argument_bytes; + ctx.globals() + .set( + "__executor_host_call", + Func::from(Async(move |path: String, arguments: String| { + let sender = sender.clone(); + let pending_calls = pending_calls.clone(); + let call_ids = call_ids.clone(); + let issue_calls = issue_calls.clone(); + async move { + if arguments.len() > argument_limit { + return Ok::<_, rquickjs::Error>(internal_result_json( + "argument_too_large", + )); + } + let parsed: JsonValue = match serde_json::from_str(&arguments) { + Ok(value) => value, + Err(_) => { + return Ok(internal_result_json("argument_not_json")); + } + }; + let response_rx = { + let _issue = issue_calls.lock().await; + let call_id = call_ids.fetch_add(1, Ordering::Relaxed); + let (response_tx, response_rx) = oneshot::channel(); + pending_calls.lock().await.insert(call_id, response_tx); + if sender + .send(WorkerMessage::ToolCall { + version: PROTOCOL_VERSION, + generation, + call_id, + path, + arguments: parsed, + }) + .await + .is_err() + { + pending_calls.lock().await.remove(&call_id); + return Ok(internal_result_json("tool_bridge_failed")); + } + response_rx + }; + match response_rx.await { + Ok(result) => serde_json::to_string(&result) + .map_err(|_| rquickjs::Error::Unknown), + Err(_) => Ok(internal_result_json("tool_bridge_failed")), + } + } + })), + ) + .map_err(js_public)?; + + let bootstrap = bootstrap_script(&limits); + ctx.eval::<(), _>(bootstrap).map_err(|error| js_caught(&ctx, error))?; + ctx.eval::<(), _>(transformed) + .map_err(|error| js_caught(&ctx, error))?; + let promise: Promise = ctx + .globals() + .get("__executor_entry") + .map_err(|error| js_caught(&ctx, error))?; + let value: Value = promise + .into_future() + .await + .catch(&ctx) + .map_err(|_| RuntimeFailure::public("execution_failed", "TypeScript execution failed"))?; + ctx.globals() + .set("__executor_result", value) + .map_err(|error| js_caught(&ctx, error))?; + let serialized: String = ctx + .eval("JSON.stringify({result: __executor_result === undefined ? null : __executor_result, emits: __executor_emits, console: __executor_console})") + .map_err(|error| js_caught(&ctx, error))?; + if serialized.len() > limits.result_bytes + limits.max_emit_bytes + limits.max_console_bytes { + return Err(RuntimeFailure::public( + "result_too_large", + "execution output exceeded its size limit", + )); + } + let collected: CollectedOutput = serde_json::from_str(&serialized).map_err(|_| { + RuntimeFailure::public("result_not_json", "execution result was not JSON serializable") + })?; + if serde_json::to_vec(&collected.result).map_or(true, |value| value.len() > limits.result_bytes) { + return Err(RuntimeFailure::public( + "result_too_large", + "execution result exceeded 8 MiB", + )); + } + Ok(collected) + }) + .await?; + runtime.idle().await; + if !pending.lock().await.is_empty() { + return Err(RuntimeFailure::internal( + "tool_bridge_failed", + "tool calls did not settle", + )); + } + Ok((collected.result, collected.emits, collected.console)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CollectedOutput { + result: JsonValue, + emits: Vec, + console: Vec, +} + +fn bootstrap_script(limits: &RuntimeLimits) -> String { + format!( + r#" + (() => {{ + 'use strict'; + const hostCall = globalThis.__executor_host_call; + delete globalThis.__executor_host_call; + const safeString = (value) => {{ + try {{ + if (typeof value === 'string') return value; + const encoded = JSON.stringify(value); + return encoded === undefined ? String(value) : encoded; + }} catch (_) {{ return '[unserializable]'; }} + }}; + const utf8Bytes = (value) => {{ + let bytes = 0; + for (const character of value) {{ + const point = character.codePointAt(0); + bytes += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4; + }} + return bytes; + }}; + globalThis.__executor_console = []; + let consoleBytes = 0; + const capture = (level, values) => {{ + if (__executor_console.length >= {console_count}) return; + const message = values.map(safeString).join(' '); + consoleBytes += utf8Bytes(message); + if (consoleBytes <= {console_bytes}) __executor_console.push({{ level, message }}); + }}; + globalThis.console = Object.freeze({{ + debug: (...v) => capture('debug', v), info: (...v) => capture('info', v), + log: (...v) => capture('log', v), warn: (...v) => capture('warn', v), + error: (...v) => capture('error', v) + }}); + globalThis.__executor_emits = []; + let emitBytes = 0; + globalThis.emit = (value) => {{ + if (__executor_emits.length >= {emit_count}) throw new Error('emit_limit_exceeded'); + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error('emit_not_json'); + emitBytes += utf8Bytes(encoded); + if (emitBytes > {emit_bytes}) throw new Error('emit_limit_exceeded'); + __executor_emits.push(JSON.parse(encoded)); + }}; + const makeTools = (path = []) => {{ + const target = (..._args) => {{}}; + delete target.name; + delete target.length; + Object.freeze(target); + return new Proxy(target, {{ + get(_target, key) {{ + if (key === 'then' || typeof key === 'symbol') return undefined; + return makeTools(path.concat(String(key))); + }}, + apply(_target, _this, args) {{ + const argument = args.length === 0 ? {{}} : args[0]; + let encoded; + try {{ encoded = JSON.stringify(argument); }} + catch (_) {{ return Promise.reject(new Error('tool_argument_not_json')); }} + if (encoded === undefined) return Promise.reject(new Error('tool_argument_not_json')); + return hostCall(path.join('.'), encoded).then((raw) => {{ + const response = JSON.parse(raw); + if (response.kind === 'success') return response.value; + if (response.kind === 'failure') return {{ error: {{ code: response.code, message: response.message }} }}; + throw new Error(response.code || 'tool_bridge_failed'); + }}); + }} + }}); + }}; + globalThis.tools = makeTools(); + for (const name of ['fetch','XMLHttpRequest','WebSocket','process','require','module','Deno','Bun']) {{ + try {{ delete globalThis[name]; }} catch (_) {{ globalThis[name] = undefined; }} + }} + }})(); + "#, + console_count = limits.max_console_entries, + console_bytes = limits.max_console_bytes, + emit_count = limits.max_emits, + emit_bytes = limits.max_emit_bytes, + ) +} + +fn internal_result_json(code: &str) -> String { + format!(r#"{{"kind":"internal_error","code":"{code}"}}"#) +} + +fn js_internal(_: rquickjs::Error) -> RuntimeFailure { + RuntimeFailure::internal( + "javascript_runtime_failed", + "could not initialize JavaScript runtime", + ) +} + +fn js_public(_: rquickjs::Error) -> RuntimeFailure { + RuntimeFailure::public("execution_failed", "TypeScript execution failed") +} + +fn js_caught<'js>(ctx: &rquickjs::Ctx<'js>, error: rquickjs::Error) -> RuntimeFailure { + let _ = (ctx, error); + RuntimeFailure::public("execution_failed", "TypeScript execution failed") +} + +#[cfg(test)] +mod tests { + use super::bootstrap_script; + use crate::runtime::RuntimeLimits; + + #[test] + fn bootstrap_does_not_embed_capabilities() { + let script = bootstrap_script(&RuntimeLimits::default()); + assert!(script.contains("delete globalThis[name]")); + assert!(!script.contains("actor")); + assert!(!script.contains("credential")); + } +} diff --git a/src/tasks.rs b/src/tasks.rs new file mode 100644 index 000000000..6c0ad8be9 --- /dev/null +++ b/src/tasks.rs @@ -0,0 +1,111 @@ +use std::{ + future::Future, + sync::{Arc, Mutex}, +}; + +use tokio::{sync::oneshot, task::AbortHandle}; + +#[derive(Clone, Default)] +pub(crate) struct TaskTracker { + state: Arc>, +} + +#[derive(Default)] +struct TaskState { + shutting_down: bool, + tasks: Vec, +} + +struct TrackedTask { + abort: AbortHandle, + completed: oneshot::Receiver<()>, +} + +struct CompletionSignal(Option>); + +impl Drop for CompletionSignal { + fn drop(&mut self) { + if let Some(completed) = self.0.take() { + let _ = completed.send(()); + } + } +} + +impl TaskTracker { + pub(crate) fn spawn(&self, future: F) -> bool + where + F: Future + Send + 'static, + { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.shutting_down { + return false; + } + state.tasks.retain(|task| !task.abort.is_finished()); + state.tasks.push(spawn_tracked(future)); + true + } + + pub(crate) fn spawn_pair(&self, first: F, second: G) -> bool + where + F: Future + Send + 'static, + G: Future + Send + 'static, + { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.shutting_down { + return false; + } + state.tasks.retain(|task| !task.abort.is_finished()); + state.tasks.push(spawn_tracked(first)); + state.tasks.push(spawn_tracked(second)); + true + } + + pub(crate) fn abort_all(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.shutting_down = true; + for task in &state.tasks { + task.abort.abort(); + } + } + + pub(crate) async fn shutdown(&self) { + let tasks = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.shutting_down = true; + std::mem::take(&mut state.tasks) + }; + for task in &tasks { + task.abort.abort(); + } + for task in tasks { + let _ = task.completed.await; + } + } +} + +fn spawn_tracked(future: F) -> TrackedTask +where + F: Future + Send + 'static, +{ + let (completed, completion) = oneshot::channel(); + let handle = tokio::spawn(async move { + let _completion = CompletionSignal(Some(completed)); + future.await; + }); + TrackedTask { + abort: handle.abort_handle(), + completed: completion, + } +} diff --git a/tests/approval_api_strictness.rs b/tests/approval_api_strictness.rs new file mode 100644 index 000000000..a435fbbe0 --- /dev/null +++ b/tests/approval_api_strictness.rs @@ -0,0 +1,179 @@ +use axum::{ + Router, + body::Body, + http::{Method, Request, StatusCode, header}, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct Admin { + cookie: String, + csrf: String, +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn json_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh app should expose a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = json_body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +async fn create_gateway_token(app: &ExecutorApp, admin: &Admin) -> String { + let response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "approval strictness" }), + &admin_headers(admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned() +} + +#[tokio::test] +async fn approval_endpoints_reject_unknown_body_fields_and_query_parameters() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + + let response = send( + app.router(), + Method::GET, + "/api/v1/approvals?limit=10&unexpected=true", + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_query"); + + let response = send( + app.router(), + Method::POST, + "/api/v1/approvals/missing/decision", + json!({ + "decision": "approve", + "expectedRevision": 0, + "unexpected": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_json"); + + let token = create_gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::DELETE, + "/api/v1/gateway/approvals/missing?expectedRevision=0&unexpected=true", + json!(null), + &[(header::AUTHORIZATION.as_str(), authorization.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_query"); + + app.shutdown().await; +} diff --git a/tests/approval_service.rs b/tests/approval_service.rs new file mode 100644 index 000000000..fceeca575 --- /dev/null +++ b/tests/approval_service.rs @@ -0,0 +1,441 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + actor::ToolActor, + approval::{ApprovalDecision, ApprovalStatus}, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + RequestSurface, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, + ToolMode, + }, + invocation::{ApprovalWaitError, ToolCall, ToolCallSubmission}, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +async fn app_with_ask_tool() -> (tempfile::TempDir, ExecutorApp) { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "INSERT INTO api_tokens \ + (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('owner-token', 'Owner', x'010203', 'exr_test', 'test', 1)", + ) + .execute(app.pool()) + .await + .expect("owner token should insert"); + sqlx::query( + "INSERT INTO admins (id, username, password_hash, created_at) \ + VALUES (1, 'admin', 'unused', 1)", + ) + .execute(app.pool()) + .await + .expect("admin should insert"); + app.catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "approval".to_owned(), + display_name: "Approval source".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { "schemes": {} } + }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "write".to_owned(), + preferred_name: "write".to_owned(), + display_name: "Write".to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["password"], + "properties": { + "password": { "type": "string", "format": "password" } + } + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Ask, + }], + }, + vec![StagedToolBinding { + stable_key: "write".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "POST".to_owned(), + path_template: "/write".to_owned(), + server_url: "http://127.0.0.1:9".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }], + AuditContext::system(Some("approval-test")), + ) + .await + .expect("ask tool should import"); + (directory, app) +} + +fn call(arguments: serde_json::Value) -> ToolCall { + call_for("execution-1", "call-1", arguments) +} + +fn call_for(execution_id: &str, call_id: &str, arguments: serde_json::Value) -> ToolCall { + ToolCall { + request_id: uuid::Uuid::new_v4().to_string(), + actor: ToolActor::api_token("owner-token", Some("Owner".to_owned())), + surface: RequestSurface::Gateway, + execution_id: execution_id.to_owned(), + call_id: call_id.to_owned(), + worker_generation: 0, + path: "approval.write".to_owned(), + arguments, + } +} + +async fn wait_for_terminal_log(app: &ExecutorApp, approval_id: &str, error_code: &str) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let (found, pending) = sqlx::query_as::<_, (i64, i64)>( + "SELECT \ + EXISTS(SELECT 1 FROM request_logs WHERE approval_id = ? AND error_code = ?), \ + EXISTS(SELECT 1 FROM approval_log_outbox WHERE approval_id = ?)", + ) + .bind(approval_id) + .bind(error_code) + .bind(approval_id) + .fetch_one(app.pool()) + .await + .expect("request log should read"); + if found != 0 && pending == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("terminal approval log {error_code} should be stored")); +} + +#[tokio::test] +async fn wait_is_correlated_cancelable_and_wakes_for_a_denial_without_plaintext_storage() { + let (directory, app) = app_with_ask_tool().await; + let secret = "unique-approval-secret-92f781"; + let submission = app + .tool_calls() + .submit(call(json!({ "password": secret }))) + .await + .expect("valid Ask call should become pending"); + let approval = match submission { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must not execute immediately"), + }; + + let admin = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read") + .expect("approval should exist"); + assert_eq!(admin.redacted_arguments["password"], "[redacted]"); + + let canceled_submission = app + .tool_calls() + .submit(call_for( + "execution-canceled-wait", + "call-canceled-wait", + json!({ "password": "cancel-wait-secret" }), + )) + .await + .expect("cancelable Ask call should become pending"); + let canceled_approval = match canceled_submission { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must not execute immediately"), + }; + let canceled = app + .tool_calls() + .wait_for_approval( + canceled_approval, + &ToolActor::api_token("owner-token", Some("Owner".to_owned())), + "execution-canceled-wait", + "call-canceled-wait", + async {}, + ) + .await; + assert!(matches!(canceled, Err(ApprovalWaitError::Canceled))); + + let service = app.tool_calls().clone(); + let approval_id = approval.id.clone(); + let approval_revision = approval.revision; + let waiter = tokio::spawn(async move { + service + .wait_for_approval( + approval, + &ToolActor::api_token("owner-token", Some("Owner".to_owned())), + "execution-1", + "call-1", + std::future::pending(), + ) + .await + }); + app.tool_calls() + .decide( + &approval_id, + "deny-request", + approval_revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("admin denial should succeed"); + let denied = tokio::time::timeout(std::time::Duration::from_secs(2), waiter) + .await + .expect("waiter should wake") + .expect("wait task should not panic") + .expect("wait should return the terminal approval"); + assert_eq!(denied.record.status, ApprovalStatus::Denied); + assert!(denied.result.is_none()); + + for entry in std::fs::read_dir(directory.path()).expect("data directory should read") { + let path = entry.expect("data entry should read").path(); + if path.is_file() { + let bytes = std::fs::read(&path).expect("data file should read"); + assert!( + !bytes + .windows(secret.len()) + .any(|window| window == secret.as_bytes()), + "plaintext approval argument leaked into {}", + path.display() + ); + } + } + + let audit_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE action = 'approval.deny' \ + AND json_extract(metadata_json, '$.approvalId') = ?", + ) + .bind(&approval_id) + .fetch_one(app.pool()) + .await + .expect("approval audit should read"); + assert_eq!(audit_count, 1); +} + +#[tokio::test] +async fn invalid_arguments_create_no_approval() { + let (_directory, app) = app_with_ask_tool().await; + let result = app + .tool_calls() + .submit(call(json!({ "unknown": true }))) + .await; + assert!(matches!( + result, + Err(executor::invocation::ToolCallError::InvalidArguments) + )); + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM approvals") + .fetch_one(app.pool()) + .await + .expect("approval count should read"); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn bulk_cancellation_and_expiry_attempt_correlated_metadata_logs() { + let (_directory, app) = app_with_ask_tool().await; + + let canceled = app + .tool_calls() + .submit(call_for( + "execution-cancel", + "call-cancel", + json!({ "password": "cancel-secret" }), + )) + .await + .expect("cancel approval should persist"); + let canceled = match canceled { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + assert_eq!( + app.tool_calls() + .cancel_execution("execution-cancel") + .await + .expect("execution cancellation should run"), + 1 + ); + wait_for_terminal_log(&app, &canceled.id, "approval_canceled").await; + + let expiring = app + .tool_calls() + .submit(call_for( + "execution-expire", + "call-expire", + json!({ "password": "expire-secret" }), + )) + .await + .expect("expiring approval should persist"); + let expiring = match expiring { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + sqlx::query("UPDATE approval_clock SET effective_now = ? WHERE id = 1") + .bind(expiring.expires_at) + .execute(app.pool()) + .await + .expect("approval clock should advance"); + assert!( + app.tool_calls() + .expire_approvals() + .await + .expect("expiry should run") + >= 1 + ); + wait_for_terminal_log(&app, &expiring.id, "approval_expired").await; +} + +#[tokio::test] +async fn token_revocation_logs_every_canceled_approval() { + let (_directory, app) = app_with_ask_tool().await; + let pending = app + .tool_calls() + .submit(call_for( + "execution-revoke", + "call-revoke", + json!({ "password": "revoke-secret" }), + )) + .await + .expect("revoked approval should persist"); + let pending = match pending { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + assert!( + app.tool_calls() + .revoke_owner_token("owner-token") + .await + .expect("token revocation should run") + ); + wait_for_terminal_log(&app, &pending.id, "approval_canceled").await; +} + +#[tokio::test] +async fn shutdown_releases_background_approval_activity_before_immediate_reopen() { + let (directory, app) = app_with_ask_tool().await; + + let pending = app + .tool_calls() + .submit(call_for( + "execution-shutdown-pending", + "call-shutdown-pending", + json!({ "password": "pending-secret" }), + )) + .await + .expect("pending approval should persist"); + let pending = match pending { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + + let approved = app + .tool_calls() + .submit(call_for( + "execution-shutdown-approved", + "call-shutdown-approved", + json!({ "password": "approved-secret" }), + )) + .await + .expect("approved candidate should persist"); + let approved = match approved { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + app.tool_calls() + .decide( + &approved.id, + "shutdown-approve", + approved.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should persist before shutdown"); + + let denied = app + .tool_calls() + .submit(call_for( + "execution-shutdown-outbox", + "call-shutdown-outbox", + json!({ "password": "outbox-secret" }), + )) + .await + .expect("denied candidate should persist"); + let denied = match denied { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + app.tool_calls() + .decide( + &denied.id, + "shutdown-deny", + denied.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("terminal outbox event should persist before shutdown"); + let config = AppConfig::new(directory.path().to_path_buf()); + app.shutdown().await; + + let reopened = ExecutorApp::open(config) + .await + .expect("shutdown should release the instance lock before returning"); + let pending_after_restart = reopened + .tool_calls() + .approvals() + .get_admin(&pending.id) + .await + .expect("pending approval should read after restart") + .expect("pending approval should remain stored after restart"); + assert_eq!(pending_after_restart.record.status, ApprovalStatus::Pending); + wait_for_terminal_log(&reopened, &denied.id, "approval_denied").await; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let recovered = reopened + .tool_calls() + .approvals() + .get_admin(&approved.id) + .await + .expect("approved recovery state should read") + .expect("approved recovery state should remain stored"); + if recovered.record.status.is_terminal() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("approved work should reach a recovery-safe terminal state"); + reopened.shutdown().await; +} diff --git a/tests/catalog.rs b/tests/catalog.rs index 79e6be26d..26ddb0848 100644 --- a/tests/catalog.rs +++ b/tests/catalog.rs @@ -241,15 +241,19 @@ async fn catalog_migration_is_strict_and_enforces_foreign_keys_and_constraints() .expect_err("closed source kind must reject unknown values"); assert!(invalid_kind.as_database_error().is_some()); - let reserved_slug = sqlx::query( - "INSERT INTO sources \ - (id, kind, slug, display_name, configuration_json, created_at, updated_at) \ - VALUES ('reserved', 'openapi', 'tools', 'Reserved', '{}', 1, 1)", - ) - .execute(executor.app.pool()) - .await - .expect_err("reserved source roots must be rejected by SQLite"); - assert!(reserved_slug.as_database_error().is_some()); + for reserved in ["tools", "search", "describe", "executor"] { + let reserved_slug = sqlx::query( + "INSERT INTO sources \ + (id, kind, slug, display_name, configuration_json, created_at, updated_at) \ + VALUES (?, 'openapi', ?, 'Reserved', '{}', 1, 1)", + ) + .bind(format!("reserved-{reserved}")) + .bind(reserved) + .execute(executor.app.pool()) + .await + .expect_err("reserved source roots must be rejected by SQLite"); + assert!(reserved_slug.as_database_error().is_some()); + } let source = executor.source("strict").await; let strict_error = sqlx::query("UPDATE sources SET display_name = ? WHERE id = ?") @@ -275,10 +279,12 @@ async fn source_and_tool_names_are_collision_safe_and_order_independent() { let second = executor.source("Git-Hub").await; assert_eq!(first.slug, "git_hub"); assert_eq!(second.slug, "git_hub_2"); - for reserved in ["tools", "search", "describe", "executor"] { + for reserved in ["tools", "search", "describe", "sources", "executor"] { let source = executor.source(reserved).await; assert_eq!(source.slug, format!("{reserved}_2")); } + let graphql = executor.source("graphql").await; + assert_eq!(graphql.slug, "graphql"); let updated = executor .app diff --git a/tests/invocation_preparation.rs b/tests/invocation_preparation.rs index 0959f91a8..110f40d27 100644 --- a/tests/invocation_preparation.rs +++ b/tests/invocation_preparation.rs @@ -95,6 +95,7 @@ async fn invocation_preparation_is_one_typed_catalog_snapshot() { assert_eq!(lease.lookup().source_id, source_id); assert_eq!(lease.lookup().tool_id, tool_id); + assert_eq!(lease.source_kind(), SourceKind::Openapi); assert_eq!(lease.lookup().effective_mode, ToolMode::Enabled); assert!(!lease.lookup().requires_approval); assert_eq!(lease.revisions().source_revision, 1); @@ -127,6 +128,60 @@ async fn invocation_preparation_is_one_typed_catalog_snapshot() { ); } +#[tokio::test] +async fn invocation_source_kind_corruption_fails_closed_for_prepare_and_revalidation() { + let (_directory, app, source_id, _tool_id) = imported_source().await; + let lease = app + .catalog() + .prepare_invocation("weather.get_weather") + .await + .expect("invocation should prepare before corruption"); + let token = lease.revisions().clone(); + drop(lease); + + let mut connection = app + .pool() + .acquire() + .await + .expect("database connection should be acquired"); + sqlx::query("PRAGMA ignore_check_constraints = ON") + .execute(&mut *connection) + .await + .expect("test should temporarily permit corrupt data"); + sqlx::query("UPDATE sources SET kind = 'corrupt' WHERE id = ?") + .bind(source_id) + .execute(&mut *connection) + .await + .expect("source kind should be corrupted for the test"); + sqlx::query("PRAGMA ignore_check_constraints = OFF") + .execute(&mut *connection) + .await + .expect("source kind constraint should be restored"); + drop(connection); + + let prepare_error = match app + .catalog() + .prepare_invocation("weather.get_weather") + .await + { + Err(error) => error, + Ok(_) => panic!("fresh preparation must reject an unknown source kind"), + }; + assert!(matches!( + prepare_error, + executor::catalog::CatalogError::CorruptData("unknown source kind") + )); + + let revalidation_error = match app.catalog().revalidate_invocation(&token).await { + Err(error) => error, + Ok(_) => panic!("revalidation must reject an unknown source kind"), + }; + assert!(matches!( + revalidation_error, + executor::catalog::CatalogError::CorruptData("unknown source kind") + )); +} + #[tokio::test] async fn invocation_lease_blocks_mutation_and_old_token_revalidation_fails_after_release() { let (_directory, app, _source_id, tool_id) = imported_source().await; @@ -175,3 +230,44 @@ async fn invocation_lease_blocks_mutation_and_old_token_revalidation_fails_after assert!(fresh.revisions().tool_revision > token.tool_revision); assert!(fresh.revisions().source_revision > token.source_revision); } + +#[tokio::test] +async fn ask_preflight_does_not_decrypt_source_credentials() { + let (_directory, app, _source_id, tool_id) = imported_source().await; + let tool = app + .catalog() + .tool(&tool_id) + .await + .expect("tool should read"); + app.catalog() + .set_tool_mode( + &tool_id, + Some(ToolMode::Ask), + tool.revision, + AuditContext::system(Some("ask-preflight")), + ) + .await + .expect("tool should become ask mode"); + sqlx::query("UPDATE source_credentials SET payload_ciphertext = x'01020304'") + .execute(app.pool()) + .await + .expect("credential ciphertext should be corrupted for the test"); + + let preflight = app + .catalog() + .preflight_invocation("weather.get_weather") + .await + .expect("ask preflight must not decrypt credentials"); + assert!(preflight.lookup().requires_approval); + assert!(preflight.arguments_are_valid(&json!({ "query": {} }))); + + let result = app + .catalog() + .revalidate_invocation(preflight.revisions()) + .await; + let error = match result { + Err(error) => error, + Ok(_) => panic!("credential decryption is deferred until approved execution"), + }; + assert!(matches!(error, executor::catalog::CatalogError::Crypto(_))); +} diff --git a/tests/openapi_api.rs b/tests/openapi_api.rs index 6942d9645..0314959e3 100644 --- a/tests/openapi_api.rs +++ b/tests/openapi_api.rs @@ -500,8 +500,10 @@ async fn preview_import_and_invoke_enforce_planes_modes_and_body_limits() { &[(header::AUTHORIZATION.as_str(), &authorization)], ) .await; - assert_eq!(ask.status(), StatusCode::CONFLICT); - assert_eq!(body(ask).await["error"]["code"], "approval_required"); + assert_eq!(ask.status(), StatusCode::ACCEPTED); + let ask = body(ask).await; + assert_eq!(ask["status"], "approval_required"); + assert_eq!(ask["approval"]["status"], "pending"); upstream_task.abort(); } @@ -637,6 +639,17 @@ async fn large_body_routes_authenticate_before_parsing_json() { assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(body(response).await["error"]["code"], "invalid_origin"); + let response = send_raw( + app.router(), + Method::POST, + "/api/v1/sources/not-a-source/refresh", + malformed.clone(), + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body(response).await["error"]["code"], "invalid_origin"); + let response = send_raw( app.router(), Method::POST, @@ -786,3 +799,178 @@ async fn openapi_credentials_reject_other_protocols_and_unknown_schema_versions( "unsupported_credential_schema" ); } + +#[tokio::test] +async fn source_requests_reject_typos_before_mutating_catalog_or_credentials() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Strict requests" }, + "servers": [{ "url": "https://example.com" }], + "paths": {} + }) + .to_string(); + + let preview_typo = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { "type": "inline", "content": specification }, + "allowPrivateNetwrok": false + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(preview_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(preview_typo).await["error"]["code"], "invalid_json"); + + let spec_typo = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { + "type": "inline", + "content": specification, + "contnet": specification + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(spec_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(spec_typo).await["error"]["code"], "invalid_json"); + + let create_typo = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayNmae": "Strict requests", + "spec": { "type": "inline", "content": specification } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(create_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(create_typo).await["error"]["code"], "invalid_json"); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources should list") + .is_empty() + ); + + let malformed_credential = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Strict requests", + "spec": { "type": "inline", "content": specification }, + "credential": {} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(malformed_credential.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(malformed_credential).await["error"]["code"], + "invalid_json" + ); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources should list") + .is_empty() + ); + + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Strict requests", + "preferredSlug": "strict", + "spec": { "type": "inline", "content": specification } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let source_id = body(created).await["id"] + .as_str() + .expect("created source should have an ID") + .to_owned(); + + let put_typo = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevison": 0, + "credential": { + "schemes": { + "ApiKey": { "type": "api_key", "value": "must-not-store" } + } + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(put_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(put_typo).await["error"]["code"], "invalid_json"); + + let put_missing_revision = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "credential": { + "schemes": { + "ApiKey": { "type": "api_key", "value": "must-not-store" } + } + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(put_missing_revision.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(put_missing_revision).await["error"]["code"], + "invalid_json" + ); + + let delete_typo = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevison=0"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(delete_typo.status(), StatusCode::BAD_REQUEST); + + let metadata = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/credentials"), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(metadata.status(), StatusCode::OK); + let metadata = body(metadata).await; + assert_eq!(metadata["revision"], 0); + assert_eq!(metadata["configuredSchemes"], json!([])); +} diff --git a/tests/openapi_atomic_create.rs b/tests/openapi_atomic_create.rs index 838a17599..76feebfd7 100644 --- a/tests/openapi_atomic_create.rs +++ b/tests/openapi_atomic_create.rs @@ -196,3 +196,25 @@ async fn malformed_input_schema_is_rejected_before_atomic_creation_writes() { assert_eq!(row_count(&app, "tools").await, 0); assert_eq!(app.catalog().global_revision().await.unwrap(), 0); } + +#[tokio::test] +async fn atomic_import_cannot_shadow_the_sources_builtin() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let mut source = source_input(); + source.preferred_slug = "sources".to_owned(); + let (source, _) = app + .catalog() + .create_source_with_catalog( + source, + &credential(), + snapshot(), + bindings(), + AuditContext::system(Some("reserved-sources-import")), + ) + .await + .expect("reserved source import should allocate a safe suffix"); + assert_eq!(source.slug, "sources_2"); +} diff --git a/tests/openapi_gateway_contract.rs b/tests/openapi_gateway_contract.rs index deb0662fa..a93927626 100644 --- a/tests/openapi_gateway_contract.rs +++ b/tests/openapi_gateway_contract.rs @@ -226,6 +226,33 @@ async fn invoke( (status, response_body(response).await) } +async fn invoke_idempotent( + app: &ExecutorApp, + token: &str, + key: &str, + path: &str, + arguments: Value, +) -> (StatusCode, Value, bool) { + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": path, "arguments": arguments }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", key), + ], + ) + .await; + let status = response.status(); + let replayed = response + .headers() + .get("idempotency-replayed") + .is_some_and(|value| value == "true"); + (status, response_body(response).await, replayed) +} + fn operation(method: &str, operation_id: &str, security: Value) -> Value { json!({ method: { @@ -700,8 +727,16 @@ async fn production_gateway_honors_openapi_security_arguments_bodies_and_modes() json!({ "body": { "hello": "write", "mode": "safe", "nested": { "id": 1 } } }), ) .await; - assert_eq!(status, StatusCode::CONFLICT); - assert_eq!(response["error"]["code"], "approval_required"); + assert_eq!(status, StatusCode::ACCEPTED); + assert_eq!(response["status"], "approval_required"); + assert_eq!(response["approval"]["status"], "pending"); + assert_eq!(response["approval"]["path"], "tools.contract.ask"); + assert!(response["approval"]["id"].as_str().is_some()); + assert!(response["approval"]["statusUrl"].as_str().is_some()); + let approval_id = response["approval"]["id"] + .as_str() + .expect("approval ID should be text") + .to_owned(); assert_eq!(recorder.count(), before); wait_for_log( &app, @@ -711,14 +746,97 @@ async fn production_gateway_honors_openapi_security_arguments_bodies_and_modes() ) .await; + let detail_uri = format!("/api/v1/approvals/{approval_id}"); + let response = send( + app.router(), + Method::GET, + &detail_uri, + json!({}), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let detail = response_body(response).await; + assert_eq!(detail["status"], "pending"); + assert_eq!(detail["redactedArguments"]["body"]["hello"], "[redacted]"); + assert!(detail.get("result").is_none()); + + let second_token = create_gateway_token(&app, &admin).await; + let second_authorization = format!("Bearer {second_token}"); + let gateway_uri = format!("/api/v1/gateway/approvals/{approval_id}"); + let response = send( + app.router(), + Method::GET, + &gateway_uri, + json!({}), + &[(header::AUTHORIZATION.as_str(), &second_authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = send( + app.router(), + Method::POST, + &format!("/api/v1/approvals/{approval_id}/decision"), + json!({ "decision": "approve", "expectedRevision": 0 }), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = send( + app.router(), + Method::POST, + &format!("/api/v1/approvals/{approval_id}/decision"), + json!({ "decision": "approve", "expectedRevision": 0 }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let replay = send( + app.router(), + Method::POST, + &format!("/api/v1/approvals/{approval_id}/decision"), + json!({ "decision": "approve", "expectedRevision": 0 }), + &admin_headers(&admin), + ) + .await; + assert!(matches!( + replay.status(), + StatusCode::OK | StatusCode::ACCEPTED + )); + + let authorization = format!("Bearer {token}"); + let mut terminal = None; + for _ in 0..100 { + let response = send( + app.router(), + Method::GET, + &gateway_uri, + json!({}), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + if body["status"] == "succeeded" { + terminal = Some(body); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let terminal = terminal.expect("approved call should complete in the background"); + assert_eq!(terminal["result"]["ok"], true); + assert_eq!(recorder.count(), before + 1); + let (status, response) = invoke(&app, &token, "contract.disabled", json!({})).await; assert_eq!(status, StatusCode::FORBIDDEN); assert_eq!(response["error"]["code"], "tool_disabled"); - assert_eq!(recorder.count(), before); + assert_eq!(recorder.count(), before + 1); wait_for_log(&app, "tools.contract.disabled", "denied", "tool_disabled").await; let last = recorder.take_last().await; - assert_eq!(last["path"], "/multi"); + assert_eq!(last["path"], "/ask"); upstream_task.abort(); } @@ -830,3 +948,518 @@ async fn invocation_holds_its_catalog_lease_until_the_upstream_request_finishes( .expect("catalog mutation should succeed"); upstream_task.abort(); } + +#[tokio::test] +async fn gateway_idempotency_validates_scopes_and_replays_exact_responses() { + let recorder = Recorder::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream = Router::new() + .fallback(record_upstream) + .with_state(recorder.clone()); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let token = create_gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {}, "unexpected": true }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "unknown-field"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(recorder.count(), 0); + let reserved = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM gateway_invocation_idempotency") + .fetch_one(app.pool()) + .await + .expect("idempotency records should count"); + assert_eq!( + reserved, 0, + "invalid JSON must not reserve an idempotency key" + ); + + for key in ["", "contains space"] { + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {} }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", key), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "invalid_idempotency_key" + ); + } + let oversized_key = "a".repeat(256); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {} }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", oversized_key.as_str()), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "invalid_idempotency_key" + ); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {} }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "duplicate-one"), + ("idempotency-key", "duplicate-two"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "invalid_idempotency_key" + ); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "export default 1" }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "execute-key"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "idempotency_not_supported" + ); + assert_eq!(recorder.count(), 0); + + let body_schema = json!({ + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { "value": { "type": "string" } } + }); + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Idempotency Contract" }, + "servers": [{ "url": format!("http://{address}") }], + "paths": { + "/enabled": { + "post": { + "operationId": "enabled", + "security": [{}], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": body_schema.clone() } } + }, + "responses": { "200": { "description": "recorded" } } + } + }, + "/ask": { + "post": { + "operationId": "ask", + "security": [{}], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": body_schema.clone() } } + }, + "responses": { "200": { "description": "recorded" } } + } + }, + "/other": { + "post": { + "operationId": "other", + "security": [{}], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": body_schema } } + }, + "responses": { "200": { "description": "recorded" } } + } + }, + "/forbidden-prepare": { + "get": { + "operationId": "forbidden_prepare", + "security": [{}], + "parameters": [{ + "name": "X-HTTP-Method-Override", + "in": "header", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "recorded" } } + } + } + } + }); + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Idempotency Contract", + "preferredSlug": "idempotency", + "spec": { "type": "inline", "content": specification.to_string() }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + set_mode(&app, "enabled", ToolMode::Enabled).await; + set_mode(&app, "forbidden_prepare", ToolMode::Enabled).await; + + let ask_arguments = json!({ + "contentType": "application/json", + "body": { "value": "same" } + }); + let (first_status, first_body, first_replayed) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(first_status, StatusCode::ACCEPTED); + assert!(!first_replayed); + let (second_status, second_body, second_replayed) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(second_status, StatusCode::ACCEPTED); + assert!(second_replayed); + assert_eq!( + second_body, first_body, + "Ask replay must be byte-equivalent JSON" + ); + let approval_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE callable_path_snapshot = 'tools.idempotency.ask'", + ) + .fetch_one(app.pool()) + .await + .expect("approvals should count"); + assert_eq!(approval_count, 1); + assert_eq!(recorder.count(), 0); + + let pruned_approval_id = first_body["approval"]["id"] + .as_str() + .expect("Ask response should include an approval ID"); + let pruned_idempotency_id = sqlx::query_scalar::<_, String>( + "SELECT id FROM gateway_invocation_idempotency WHERE approval_id = ?", + ) + .bind(pruned_approval_id) + .fetch_one(app.pool()) + .await + .expect("Ask approval should be linked to its idempotency result"); + let mut prune_connection = app + .pool() + .acquire() + .await + .expect("approval retention connection should open"); + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut *prune_connection) + .await + .expect("approval retention should enforce foreign keys"); + let deleted = sqlx::query("DELETE FROM approvals WHERE id = ?") + .bind(pruned_approval_id) + .execute(&mut *prune_connection) + .await + .expect("approval retention should be reproducible") + .rows_affected(); + drop(prune_connection); + assert_eq!(deleted, 1); + let retained_idempotency = sqlx::query_as::<_, (String, Option)>( + "SELECT state, approval_id FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(pruned_idempotency_id) + .fetch_one(app.pool()) + .await + .expect("completed idempotency result should outlive its pruned approval"); + assert_eq!(retained_idempotency.0, "completed"); + assert_eq!(retained_idempotency.1, None); + + let (pruned_status, pruned_body, pruned_replayed) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(pruned_status, StatusCode::CONFLICT); + assert_eq!(pruned_body["error"]["code"], "idempotency_outcome_unknown"); + assert!(!pruned_replayed, "a dead Ask response must not be replayed"); + let approval_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE callable_path_snapshot = 'tools.idempotency.ask'", + ) + .fetch_one(app.pool()) + .await + .expect("approvals should count after the retry"); + assert_eq!(approval_count, 0, "the retry must not create an approval"); + assert_eq!(recorder.count(), 0, "the retry must not call upstream"); + + sqlx::query( + "CREATE TRIGGER reject_ask_idempotency_completion \ + BEFORE UPDATE OF state ON gateway_invocation_idempotency \ + WHEN OLD.state = 'reserved' AND NEW.state = 'completed' AND NEW.approval_id IS NOT NULL \ + BEGIN SELECT RAISE(ABORT, 'forced Ask completion failure'); END", + ) + .execute(app.pool()) + .await + .expect("completion failure trigger should install"); + let (failed_status, _, failed_replayed) = invoke_idempotent( + &app, + &token, + "ask-completion-failure", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(failed_status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(!failed_replayed); + sqlx::query("DROP TRIGGER reject_ask_idempotency_completion") + .execute(app.pool()) + .await + .expect("completion failure trigger should be removed"); + let stranded = sqlx::query_as::<_, (String, i64)>( + "SELECT state, (SELECT COUNT(*) FROM approvals \ + WHERE execution_id = 'gateway-idempotency:' || gateway_invocation_idempotency.id) \ + FROM gateway_invocation_idempotency WHERE state = 'indeterminate' \ + ORDER BY sequence DESC LIMIT 1", + ) + .fetch_one(app.pool()) + .await + .expect("failed completion should fail closed with its correlated approval"); + assert_eq!(stranded, ("indeterminate".to_owned(), 1)); + + let (failed_retry_status, failed_retry_body, failed_retry_replayed) = invoke_idempotent( + &app, + &token, + "ask-completion-failure", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(failed_retry_status, StatusCode::CONFLICT); + assert_eq!( + failed_retry_body["error"]["code"], + "idempotency_outcome_unknown" + ); + assert!(!failed_retry_replayed); + let matching_approvals = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE callable_path_snapshot = 'tools.idempotency.ask'", + ) + .fetch_one(app.pool()) + .await + .expect("failed completion approval should count"); + assert_eq!( + matching_approvals, 1, + "failed-closed retry must not duplicate the approval" + ); + assert_eq!( + recorder.count(), + 0, + "failed-closed retry must not call upstream" + ); + + let (status, body, _) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.other", + ask_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "idempotency_key_mismatch"); + let (status, body, _) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + json!({ + "contentType": "application/json", + "body": { "value": "different" } + }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "idempotency_key_mismatch"); + + let second_token = create_gateway_token(&app, &admin).await; + let (first_owner_status, first_owner, first_owner_replayed) = invoke_idempotent( + &app, + &token, + "shared-across-tokens", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(first_owner_status, StatusCode::ACCEPTED); + assert!(!first_owner_replayed); + let (second_owner_status, second_owner, second_owner_replayed) = invoke_idempotent( + &app, + &second_token, + "shared-across-tokens", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(second_owner_status, StatusCode::ACCEPTED); + assert!(!second_owner_replayed); + assert_ne!( + first_owner["approval"]["id"], + second_owner["approval"]["id"] + ); + + let enabled_arguments = json!({ + "contentType": "application/json", + "body": { "value": "enabled" } + }); + let before_prepare_failure = recorder.count(); + let reserved_before_failure = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM gateway_invocation_idempotency WHERE state = 'reserved'", + ) + .fetch_one(app.pool()) + .await + .expect("reserved idempotency records should count"); + let (status, body, replayed) = invoke_idempotent( + &app, + &token, + "released-prepare-failure", + "idempotency.forbidden_prepare", + json!({ "headers": { "X-HTTP-Method-Override": "DELETE" } }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "forbidden_tool_header"); + assert!(!replayed); + assert_eq!(recorder.count(), before_prepare_failure); + let reserved_after_failure = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM gateway_invocation_idempotency WHERE state = 'reserved'", + ) + .fetch_one(app.pool()) + .await + .expect("reserved idempotency records should count"); + assert_eq!(reserved_after_failure, reserved_before_failure); + let (status, _, replayed) = invoke_idempotent( + &app, + &token, + "released-prepare-failure", + "idempotency.enabled", + enabled_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(!replayed); + assert_eq!(recorder.count(), before_prepare_failure + 1); + + let before_enabled = recorder.count(); + let (status, first_enabled, replayed) = invoke_idempotent( + &app, + &token, + "enabled-replay", + "idempotency.enabled", + enabled_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(!replayed); + let (status, second_enabled, replayed) = invoke_idempotent( + &app, + &token, + "enabled-replay", + "idempotency.enabled", + enabled_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(replayed); + assert_eq!(second_enabled, first_enabled); + assert_eq!(recorder.count(), before_enabled + 1); + + let concurrent_before = recorder.count(); + let mut invocations = Vec::new(); + for _ in 0..12 { + let router = app.router(); + let token = token.clone(); + let arguments = enabled_arguments.clone(); + invocations.push(tokio::spawn(async move { + let authorization = format!("Bearer {token}"); + let response = send( + router, + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "idempotency.enabled", "arguments": arguments }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "enabled-concurrent"), + ], + ) + .await; + (response.status(), response_body(response).await) + })); + } + for invocation in invocations { + let (status, body) = invocation.await.expect("invocation task should join"); + assert!( + matches!(status, StatusCode::OK | StatusCode::CONFLICT), + "unexpected concurrent response {status}: {body}" + ); + if status == StatusCode::CONFLICT { + assert_eq!(body["error"]["code"], "idempotency_in_progress"); + } + } + assert_eq!(recorder.count(), concurrent_before + 1); + let (status, _, replayed) = invoke_idempotent( + &app, + &token, + "enabled-concurrent", + "idempotency.enabled", + enabled_arguments, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(replayed); + assert_eq!(recorder.count(), concurrent_before + 1); + + upstream_task.abort(); +} diff --git a/tests/openapi_lifecycle.rs b/tests/openapi_lifecycle.rs index 8ef685499..4f47a0088 100644 --- a/tests/openapi_lifecycle.rs +++ b/tests/openapi_lifecycle.rs @@ -1,4 +1,10 @@ -use std::{path::Path, sync::Arc}; +use std::{ + path::Path, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; use axum::{ Json, Router, @@ -26,18 +32,25 @@ struct Admin { struct Upstream { address: std::net::SocketAddr, specification: Arc>, + spec_requests: Arc, task: tokio::task::JoinHandle<()>, } +#[derive(Clone)] +struct UpstreamState { + specification: Arc>, + spec_requests: Arc, +} + impl Upstream { async fn start() -> Self { - async fn serve_specification( - State(specification): State>>, - ) -> Json { - Json(specification.read().await.clone()) + async fn serve_specification(State(state): State) -> Json { + state.spec_requests.fetch_add(1, Ordering::SeqCst); + Json(state.specification.read().await.clone()) } let specification = Arc::new(RwLock::new(json!({}))); + let spec_requests = Arc::new(AtomicUsize::new(0)); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("upstream listener should bind"); @@ -48,7 +61,10 @@ impl Upstream { "/api/hello", get(|| async { Json(json!({ "message": "still callable" })) }), ) - .with_state(Arc::clone(&specification)); + .with_state(UpstreamState { + specification: Arc::clone(&specification), + spec_requests: Arc::clone(&spec_requests), + }); let task = tokio::spawn(async move { axum::serve(listener, router) .await @@ -57,6 +73,7 @@ impl Upstream { Self { address, specification, + spec_requests, task, } } @@ -76,6 +93,10 @@ impl Upstream { None => format!("http://{}/openapi.json", self.address), } } + + fn spec_request_count(&self) -> usize { + self.spec_requests.load(Ordering::SeqCst) + } } impl Drop for Upstream { @@ -253,6 +274,71 @@ fn hello_paths() -> Value { }) } +#[tokio::test] +async fn refresh_rejects_unknown_fields_before_fetching_or_mutating() { + let upstream = Upstream::start().await; + upstream.set_paths(hello_paths(), "initial").await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "strict-refresh", + json!({ "schemes": {} }), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let before_source = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let before_global_revision = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let before_spec_requests = upstream.spec_request_count(); + + let response = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({ "expectedRevison": before_source.revision }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_json"); + assert_eq!(upstream.spec_request_count(), before_spec_requests); + + let after_source = app + .catalog() + .source(&source_id) + .await + .expect("source should still read"); + assert_eq!(after_source.revision, before_source.revision); + assert_eq!( + after_source.catalog_revision, + before_source.catalog_revision + ); + assert_eq!(after_source.tool_count, before_source.tool_count); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should still read"), + before_global_revision + ); +} + #[tokio::test] async fn removed_then_restored_openapi_tool_keeps_identity_override_and_callable_binding() { let upstream = Upstream::start().await; @@ -267,7 +353,7 @@ async fn removed_then_restored_openapi_tool_keeps_identity_override_and_callable &admin, &upstream.spec_url(None), "lifecycle", - json!({}), + json!({ "schemes": {} }), ) .await; let source_id = created["id"] @@ -371,7 +457,7 @@ async fn refresh_binding_failure_rolls_back_artifact_tools_bindings_and_revision &admin, &upstream.spec_url(None), "rollback", - json!({}), + json!({ "schemes": {} }), ) .await; let source_id = created["id"] diff --git a/tests/openapi_parser.rs b/tests/openapi_parser.rs index 0ef29407f..a1329e819 100644 --- a/tests/openapi_parser.rs +++ b/tests/openapi_parser.rs @@ -1,8 +1,8 @@ use executor::{ catalog::ToolMode, openapi::{ - OpenApiCredential, OpenApiCredentialError, OpenApiCredentialSet, OpenApiError, - OpenApiInvocationError, OpenApiParameterLocation, OpenApiSecurityScheme, + OpenApiBinding, OpenApiCredential, OpenApiCredentialError, OpenApiCredentialSet, + OpenApiError, OpenApiInvocationError, OpenApiParameterLocation, OpenApiSecurityScheme, build_protocol_request, build_protocol_request_with_base, compile_document, }, }; @@ -1103,10 +1103,7 @@ fn public_request_builder_rejects_identity_and_rewrite_headers() { #[test] fn canonical_credentials_validate_and_round_trip_every_supported_type() { - assert_eq!( - serde_json::from_value::(json!({})).unwrap(), - OpenApiCredentialSet::default() - ); + assert!(serde_json::from_value::(json!({})).is_err()); let credentials: OpenApiCredentialSet = serde_json::from_value(json!({ "schemes": { "headerKey": { "type": "api_key", "value": "key" }, @@ -1153,6 +1150,74 @@ fn canonical_credentials_validate_and_round_trip_every_supported_type() { ); } +#[test] +fn public_openapi_dtos_reject_unknown_or_legacy_credential_fields() { + for value in [ + json!({ "scheme": {} }), + json!({ "schemes": {}, "unexpected": true }), + json!({ "schemes": { "key": { "type": "api_key", "value": "key", "typo": true } } }), + json!({ "schemes": { "bearer": { "type": "bearer", "token": "token", "typo": true } } }), + json!({ "schemes": { "basic": { + "type": "basic", "username": "user", "password": "password", "typo": true + } } }), + json!({ "schemes": { "oauth": { + "type": "oauth_access_token", "access_token": "token", "typo": true + } } }), + json!({ "schemes": { "oauth": { + "type": "oauth_access_token", "accessToken": "legacy-token" + } } }), + ] { + assert!( + serde_json::from_value::(value).is_err(), + "unknown and legacy fields must be rejected" + ); + } + + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Strict binding DTO" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "key": { "type": "apiKey", "in": "header", "name": "X-Api-Key" } + }}, + "paths": { "/items": { "post": { + "parameters": [{ "name": "limit", "in": "query", "schema": { "type": "integer" } }], + "requestBody": { "content": { + "application/json": { "schema": { "type": "object" } } + }}, + "security": [{ "key": [] }], + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let binding = &compiled.tools[0].binding; + let serialized = serde_json::to_value(binding).unwrap(); + assert_eq!( + serde_json::from_value::(serialized.clone()).unwrap(), + *binding + ); + + for pointer in [ + "", + "/parameters/0", + "/requestBody", + "/security/0", + "/security/0/requirements/0", + "/security/0/requirements/0/scheme", + ] { + let mut invalid = serialized.clone(); + invalid + .pointer_mut(pointer) + .and_then(serde_json::Value::as_object_mut) + .expect("test pointer should select an object") + .insert("unexpected".to_owned(), json!(true)); + assert!( + serde_json::from_value::(invalid).is_err(), + "unknown field at {pointer} must be rejected" + ); + } +} + #[test] fn canonical_builder_applies_api_key_basic_bearer_and_oauth_credentials() { let document = json!({ diff --git a/tests/runtime.rs b/tests/runtime.rs new file mode 100644 index 000000000..978a5b67a --- /dev/null +++ b/tests/runtime.rs @@ -0,0 +1,516 @@ +use std::{ + collections::HashMap, + sync::{ + Arc, OnceLock, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use async_trait::async_trait; +use executor::runtime::{ + ExecutionCancellation, ExecutionRequest, HostToolDispatcher, RuntimeManager, ToolCall, + ToolResult, +}; +use serde_json::{Value, json}; +use tokio::sync::{Barrier, Mutex}; + +fn manager() -> RuntimeManager { + RuntimeManager::new(env!("CARGO_BIN_EXE_executor")) +} + +fn request(code: &str) -> ExecutionRequest { + ExecutionRequest { + execution_id: uuid::Uuid::new_v4().to_string(), + code: code.into(), + timeout: Duration::from_secs(5), + } +} + +async fn test_guard() -> tokio::sync::MutexGuard<'static, ()> { + static TEST_LOCK: OnceLock> = OnceLock::new(); + TEST_LOCK.get_or_init(|| Mutex::new(())).lock().await +} + +struct EchoDispatcher; + +#[async_trait] +impl HostToolDispatcher for EchoDispatcher { + async fn dispatch(&self, call: ToolCall, _: ExecutionCancellation) -> ToolResult { + ToolResult::Success { + value: json!({ "path": call.path, "arguments": call.arguments }), + } + } +} + +#[tokio::test] +async fn executes_typescript_with_proxy_console_and_emit() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + r#" + const count: number = 2; + console.info("calling", count); + emit({ phase: "ready" }); + const response = await tools.weather["forecast"]({ city: "Paris", count }); + return { response, missingThen: tools.weather.then === undefined }; + "#, + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!(output.result["response"]["path"], "weather.forecast"); + assert_eq!(output.result["response"]["arguments"]["count"], 2); + assert_eq!(output.result["missingThen"], true); + assert_eq!(output.emits, vec![json!({ "phase": "ready" })]); + assert_eq!(output.console[0].message, "calling 2"); + assert_eq!(output.tool_calls.len(), 1); +} + +struct DependencyDispatcher; + +#[async_trait] +impl HostToolDispatcher for DependencyDispatcher { + async fn dispatch(&self, call: ToolCall, _: ExecutionCancellation) -> ToolResult { + let value = match call.path.as_str() { + "sequence.first" => json!(7), + "sequence.second" => json!(call.arguments["value"].as_i64().unwrap_or_default() * 2), + _ => Value::Null, + }; + ToolResult::Success { value } + } +} + +#[tokio::test] +async fn sequential_calls_can_depend_on_prior_results() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "const first = await tools.sequence.first(); return await tools.sequence.second({ value: first });", + ), + Arc::new(DependencyDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!(output.result, 14); + assert_eq!( + output + .tool_calls + .iter() + .map(|call| call.call_id) + .collect::>(), + vec![1, 2] + ); +} + +struct ConcurrentDispatcher { + barrier: Barrier, + active: AtomicUsize, + peak: AtomicUsize, + releases: Mutex>, +} + +#[async_trait] +impl HostToolDispatcher for ConcurrentDispatcher { + async fn dispatch(&self, call: ToolCall, _: ExecutionCancellation) -> ToolResult { + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.peak.fetch_max(active, Ordering::SeqCst); + self.barrier.wait().await; + let delay = self.releases.lock().await[&call.path]; + tokio::time::sleep(Duration::from_millis(delay)).await; + self.active.fetch_sub(1, Ordering::SeqCst); + ToolResult::Success { + value: json!(call.path), + } + } +} + +#[tokio::test] +async fn promise_all_overlaps_and_preserves_input_order() { + let _guard = test_guard().await; + let dispatcher = Arc::new(ConcurrentDispatcher { + barrier: Barrier::new(2), + active: AtomicUsize::new(0), + peak: AtomicUsize::new(0), + releases: Mutex::new(HashMap::from([ + ("parallel.slow".into(), 50), + ("parallel.fast".into(), 0), + ])), + }); + let output = manager() + .execute( + request("return await Promise.all([tools.parallel.slow(), tools.parallel.fast()]);"), + dispatcher.clone(), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!(dispatcher.peak.load(Ordering::SeqCst), 2); + assert_eq!(output.result, json!(["parallel.slow", "parallel.fast"])); +} + +#[tokio::test] +async fn unawaited_started_calls_settle_before_completion() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "tools.background.started({ value: 1 }); await Promise.resolve(); return 'done';", + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("started call should settle before completion"); + assert_eq!(output.result, "done"); + assert_eq!(output.tool_calls.len(), 1); + assert_eq!(output.tool_calls[0].path, "background.started"); +} + +#[tokio::test] +async fn expected_tool_failures_are_values_not_rejections() { + let _guard = test_guard().await; + struct FailureDispatcher; + #[async_trait] + impl HostToolDispatcher for FailureDispatcher { + async fn dispatch(&self, _: ToolCall, _: ExecutionCancellation) -> ToolResult { + ToolResult::Failure { + code: "upstream_denied".into(), + message: "denied".into(), + } + } + } + let output = manager() + .execute( + request("return await tools.example.fail();"), + Arc::new(FailureDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("expected failure should remain a value"); + assert_eq!(output.result["error"]["code"], "upstream_denied"); +} + +#[tokio::test] +async fn cpu_loops_and_unresolved_promises_time_out_without_poisoning_next_execution() { + let _guard = test_guard().await; + let mut timed = request("while (true) {};"); + timed.timeout = Duration::from_millis(150); + assert_eq!( + manager() + .execute( + timed, + Arc::new(EchoDispatcher), + ExecutionCancellation::default() + ) + .await + .expect_err("loop must time out") + .code, + "execution_timeout" + ); + + let output = manager() + .execute( + request("return 42;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("next worker must be isolated"); + assert_eq!(output.result, 42); + + let mut unresolved = request("return await new Promise(() => {});"); + unresolved.timeout = Duration::from_millis(100); + assert_eq!( + manager() + .execute( + unresolved, + Arc::new(EchoDispatcher), + ExecutionCancellation::default() + ) + .await + .expect_err("unresolved promise must time out") + .code, + "execution_timeout" + ); +} + +#[tokio::test] +async fn cancellation_terminates_pending_dispatch() { + let _guard = test_guard().await; + struct PendingDispatcher; + #[async_trait] + impl HostToolDispatcher for PendingDispatcher { + async fn dispatch(&self, _: ToolCall, cancellation: ExecutionCancellation) -> ToolResult { + cancellation.cancelled().await; + ToolResult::Failure { + code: "cancelled".into(), + message: "cancelled".into(), + } + } + } + let cancellation = ExecutionCancellation::default(); + let cancel_from_task = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + cancel_from_task.cancel(); + }); + let failure = manager() + .execute( + request("return await tools.wait.forever();"), + Arc::new(PendingDispatcher), + cancellation, + ) + .await + .expect_err("execution must cancel"); + assert_eq!(failure.code, "execution_cancelled"); +} + +#[tokio::test] +async fn dispatcher_failure_stops_execution_without_waiting_for_wall_timeout() { + let _guard = test_guard().await; + struct PanicDispatcher; + #[async_trait] + impl HostToolDispatcher for PanicDispatcher { + async fn dispatch(&self, _: ToolCall, _: ExecutionCancellation) -> ToolResult { + panic!("intentional dispatcher failure") + } + } + let started = std::time::Instant::now(); + let failure = manager() + .execute( + request("return await tools.failure.panic();"), + Arc::new(PanicDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("dispatcher panic should fail the execution"); + assert_eq!(failure.code, "tool_bridge_failed"); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[tokio::test] +async fn unavailable_host_globals_stay_unavailable() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "return [typeof fetch, typeof process, typeof require, typeof Deno, typeof Bun];", + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!( + output.result, + json!([ + "undefined", + "undefined", + "undefined", + "undefined", + "undefined" + ]) + ); +} + +#[tokio::test] +async fn stack_memory_and_output_limits_fail_inside_one_worker() { + let _guard = test_guard().await; + for (code, forbidden_failure) in [ + ( + "function recurse() { return recurse(); }", + "execution_timeout", + ), + ( + "const values = []; while (true) { values.push('x'.repeat(1024 * 1024)); }", + "execution_timeout", + ), + ("return 'x'.repeat(9 * 1024 * 1024);", "execution_timeout"), + ] { + let mut bounded = request(code); + bounded.timeout = Duration::from_secs(2); + let failure = manager() + .execute( + bounded, + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("hostile execution must fail"); + assert_ne!( + failure.code, forbidden_failure, + "engine limit must fire: {code}" + ); + } +} + +#[tokio::test] +async fn console_emit_and_call_caps_are_enforced() { + let _guard = test_guard().await; + let output = manager() + .execute( + request("for (let i = 0; i < 1100; i++) console.log(i); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("console capture should truncate safely"); + assert_eq!(output.console.len(), 1000); + + let unicode = manager() + .execute( + request("console.log('😀'.repeat(70000)); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("oversized unicode console entry should truncate safely"); + assert!(unicode.console.is_empty()); + + let emit_failure = manager() + .execute( + request("for (let i = 0; i < 101; i++) emit(i); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("emit cap must fail"); + assert_eq!(emit_failure.code, "execution_failed"); + + let unicode_emit_failure = manager() + .execute( + request("emit('😀'.repeat(2100000)); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("UTF-8 emit cap must fail"); + assert_eq!(unicode_emit_failure.code, "execution_failed"); + + let call_failure = manager() + .execute( + request("return await Promise.all(Array.from({length: 129}, (_, i) => tools.cap.call({i})));"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("call cap must fail"); + assert_eq!(call_failure.code, "tool_call_limit_exceeded"); +} + +#[tokio::test] +async fn proxy_ignores_symbols_and_hostile_json_does_not_mutate_prototypes() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "const result = await tools.prototype.check(JSON.parse('{\"__proto__\":{\"polluted\":true}}')); return { symbol: tools[Symbol.iterator] === undefined, polluted: ({}).polluted ?? null, result };", + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("hostile JSON should remain data"); + assert_eq!(output.result["symbol"], true); + assert_eq!(output.result["polluted"], Value::Null); + assert_eq!( + output.result["result"]["arguments"]["__proto__"]["polluted"], + true + ); +} + +#[tokio::test] +async fn parallel_executions_have_fresh_globals() { + let _guard = test_guard().await; + let manager = manager(); + let first = manager.execute( + request("globalThis.secret = 99; return secret;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ); + let second = manager.execute( + request("return typeof secret;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ); + let (first, second) = tokio::join!(first, second); + assert_eq!(first.expect("first execution should succeed").result, 99); + assert_eq!( + second.expect("second execution should succeed").result, + "undefined" + ); +} + +#[tokio::test] +async fn worker_crash_isolated_from_following_execution() { + let _guard = test_guard().await; + let failure = RuntimeManager::new("/bin/true") + .execute( + request("return 1;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("exited worker must fail"); + assert!(failure.internal); + + let output = manager() + .execute( + request("return 2;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("next worker must remain healthy"); + assert_eq!(output.result, 2); +} + +#[tokio::test] +async fn dropping_waiters_cleans_workers_before_releasing_slots() { + let _guard = test_guard().await; + let mut waiters = Vec::new(); + for _ in 0..8 { + waiters.push(tokio::spawn(async move { + manager() + .execute( + request("while (true) {}"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + })); + } + tokio::time::sleep(Duration::from_millis(50)).await; + let busy = manager() + .execute( + request("return 'no slot';"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("ninth worker must fail without queueing"); + assert_eq!(busy.code, "runtime_busy"); + for waiter in waiters { + waiter.abort(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + + let output = tokio::time::timeout( + Duration::from_secs(2), + manager().execute( + request("return 'reaped';"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ), + ) + .await + .expect("worker slot should be released") + .expect("replacement execution should succeed"); + assert_eq!(output.result, "reaped"); +} diff --git a/tests/runtime_invocation.rs b/tests/runtime_invocation.rs new file mode 100644 index 000000000..3c4f340dd --- /dev/null +++ b/tests/runtime_invocation.rs @@ -0,0 +1,1339 @@ +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + extract::{OriginalUri, State}, + http::{Method, Request, StatusCode, header}, +}; +use executor::{ + AppConfig, ExecutorApp, + actor::ToolActor, + approval::{ApprovalDecision, ApprovalListQuery, ApprovalStatus}, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + RequestSurface, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, + ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, + runtime::{ + ExecutionCancellation, ExecutionRequest, HostToolDispatcher, InvocationContext, + InvocationToolDispatcher, RuntimeManager, ToolCall as RuntimeToolCall, + ToolResult as RuntimeToolResult, + }, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tokio::{net::TcpListener, sync::Notify}; +use tower::ServiceExt; + +#[derive(Clone, Default)] +struct UpstreamState { + block_ask: Arc, + entered: Arc, + release: Arc, +} + +async fn upstream( + State(state): State, + OriginalUri(uri): OriginalUri, +) -> Json { + if state.block_ask.load(Ordering::Acquire) { + state.entered.notify_one(); + state.release.notified().await; + } + Json(json!({ "path": uri.path() })) +} + +async fn api_request( + router: Router, + method: Method, + uri: &str, + body: String, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body)) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn api_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn response_cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn fixture() -> ( + tempfile::TempDir, + ExecutorApp, + UpstreamState, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream_state = UpstreamState::default(); + let server_state = upstream_state.clone(); + let upstream_task = tokio::spawn(async move { + axum::serve( + listener, + Router::new().fallback(upstream).with_state(server_state), + ) + .await + .expect("upstream should serve"); + }); + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "INSERT INTO api_tokens (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('runtime-owner', 'Runtime owner', x'010203', 'exr_test', 'test', 1)", + ) + .execute(app.pool()) + .await + .expect("owner token should insert"); + sqlx::query( + "INSERT INTO admins (id, username, password_hash, created_at) \ + VALUES (1, 'admin', 'unused', 1)", + ) + .execute(app.pool()) + .await + .expect("admin should insert"); + + let definitions = [ + ("enabled", ToolMode::Enabled), + ("first", ToolMode::Ask), + ("second", ToolMode::Ask), + ("third", ToolMode::Ask), + ("deny", ToolMode::Ask), + ("cancel", ToolMode::Ask), + ("revoke", ToolMode::Ask), + ]; + let tools = definitions + .iter() + .map(|(name, mode)| StagedTool { + stable_key: (*name).to_owned(), + preferred_name: (*name).to_owned(), + display_name: (*name).to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: *mode, + }) + .collect(); + let bindings = definitions + .iter() + .map(|(name, _)| StagedToolBinding { + stable_key: (*name).to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: format!("/{name}"), + server_url: format!("http://{address}"), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }) + .collect(); + app.catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "runtime".to_owned(), + display_name: "Runtime".to_owned(), + description: None, + configuration: Map::from_iter([ + ("spec".to_owned(), json!({ "type": "inline" })), + ("allowPrivateNetwork".to_owned(), Value::Bool(true)), + ]), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { "schemes": {} } + }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools, + }, + bindings, + AuditContext::system(Some("runtime-invocation-test")), + ) + .await + .expect("runtime fixture should import"); + (directory, app, upstream_state, upstream_task) +} + +fn execution( + app: &ExecutorApp, + execution_id: &str, +) -> (RuntimeManager, Arc) { + let dispatcher = Arc::new(InvocationToolDispatcher::new( + app.tool_calls().clone(), + InvocationContext { + request_id: uuid::Uuid::new_v4().to_string(), + actor: ToolActor::api_token("runtime-owner", Some("Runtime owner".to_owned())), + surface: RequestSurface::Gateway, + execution_id: execution_id.to_owned(), + }, + )); + ( + RuntimeManager::new(env!("CARGO_BIN_EXE_executor")), + dispatcher, + ) +} + +async fn pending( + app: &ExecutorApp, + execution_id: &str, + count: usize, +) -> Vec { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let approvals = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + limit: 100, + status: Some(ApprovalStatus::Pending), + ..Default::default() + }) + .await + .expect("approvals should list") + .items + .into_iter() + .filter(|approval| approval.execution_id == execution_id) + .collect::>(); + if approvals.len() == count { + break approvals; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("pending approvals should appear") +} + +#[tokio::test] +async fn real_worker_dispatches_enabled_openapi_and_builtin_discovery() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-enabled"; + let (manager, dispatcher) = execution(&app, execution_id); + let output = manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "const found = await tools.search({query: 'enabled'}); const described = await tools.describe({path: 'runtime.enabled'}); const sources = await tools.sources(); const result = await tools.runtime.enabled(); return {found: found.total, described: described.path, sources: sources.length, result};".to_owned(), + timeout: Duration::from_secs(5), + }, + dispatcher.clone(), + ExecutionCancellation::default(), + ) + .await + .expect("enabled execution should succeed"); + dispatcher.finish().await; + assert_eq!(output.result["described"], "runtime.enabled"); + assert_eq!(output.result["sources"], 1); + assert_eq!(output.result["result"]["path"], "/enabled"); + assert!( + output.result["found"] + .as_u64() + .is_some_and(|count| count >= 1) + ); + upstream_task.abort(); +} + +#[tokio::test] +async fn concurrent_ask_calls_settle_independently_in_javascript_order() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-concurrent-ask"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await Promise.all([tools.runtime.first(), tools.runtime.second(), tools.runtime.third()]);" + .to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approvals = pending(&app, execution_id, 3).await; + let first = approvals + .iter() + .find(|approval| approval.callable_path_snapshot.ends_with(".first")) + .expect("first approval should exist"); + let second = approvals + .iter() + .find(|approval| approval.callable_path_snapshot.ends_with(".second")) + .expect("second approval should exist"); + let third = approvals + .iter() + .find(|approval| approval.callable_path_snapshot.ends_with(".third")) + .expect("third approval should exist"); + assert!(first.worker_generation > 0 && first.worker_generation <= i64::MAX as u64); + assert_eq!(first.worker_generation, second.worker_generation); + app.tool_calls() + .decide( + &second.id, + "approve-second", + second.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("second approval should succeed"); + app.tool_calls() + .decide( + &third.id, + "deny-third", + third.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("third denial should succeed"); + app.tool_calls() + .decide( + &first.id, + "approve-first", + first.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("first approval should succeed"); + let output = execution + .await + .expect("execution task should not panic") + .expect("approved execution should succeed"); + dispatcher.finish().await; + assert_eq!(output.result[0]["path"], "/first", "{}", output.result); + assert_eq!(output.result[1]["path"], "/second"); + assert_eq!(output.result[2]["error"]["code"], "approval_denied"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let distinct = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(DISTINCT request_id) FROM request_logs \ + WHERE approval_id IN (?, ?, ?)", + ) + .bind(&first.id) + .bind(&second.id) + .bind(&third.id) + .fetch_one(app.pool()) + .await + .expect("request logs should read"); + if distinct >= 3 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("concurrent calls should have distinct request IDs"); + upstream_task.abort(); +} + +#[tokio::test] +async fn catalog_revision_change_marks_waiting_approval_stale() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-stale"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.first();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .into_iter() + .find(|tool| tool.local_name == "first") + .expect("Ask tool should exist"); + app.catalog() + .set_tool_mode( + &tool.id, + Some(ToolMode::Enabled), + tool.revision, + AuditContext::system(Some("runtime-stale-test")), + ) + .await + .expect("tool revision should change"); + app.tool_calls() + .decide( + &approval.id, + "approve-stale", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval decision should persist"); + let output = execution + .await + .expect("execution task should not panic") + .expect("stale approval should settle as a value"); + assert_eq!(output.result["error"]["code"], "approval_stale"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn dropped_runtime_waiter_still_cancels_pending_approval() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-dropped-waiter"; + let (manager, dispatcher) = execution(&app, execution_id); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.cancel();".to_owned(), + timeout: Duration::from_secs(5), + }, + dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + execution.abort(); + assert!( + execution + .await + .expect_err("request waiter should be aborted") + .is_cancelled() + ); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let detail = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read") + .expect("approval should remain stored"); + if detail.record.status == ApprovalStatus::Canceled { + assert_eq!(detail.record.revision, 1); + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("detached actor should complete approval cleanup"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let pins = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approval_delivery_pins WHERE approval_id = ?", + ) + .bind(&approval.id) + .fetch_one(app.pool()) + .await + .expect("dropped waiter delivery pin should read"); + if pins == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped waiter delivery pin should be released"); + upstream_task.abort(); +} + +#[tokio::test] +async fn cancellation_during_blocked_submission_leaves_no_delivery_pin() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-cancel-during-submit"; + let (_manager, dispatcher) = execution(&app, execution_id); + let writer = app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("submission blocker should acquire the SQLite writer"); + let cancellation = ExecutionCancellation::default(); + let dispatch_cancellation = cancellation.clone(); + let dispatch = tokio::spawn({ + let dispatcher = dispatcher.clone(); + async move { + dispatcher + .dispatch( + RuntimeToolCall { + execution_id: execution_id.to_owned(), + worker_generation: 11, + call_id: 1, + path: "runtime.cancel".to_owned(), + arguments: json!({}), + }, + dispatch_cancellation, + ) + .await + } + }); + for _ in 0..16 { + tokio::task::yield_now().await; + } + tokio::time::sleep(Duration::from_millis(100)).await; + cancellation.cancel(); + writer + .commit() + .await + .expect("submission blocker should release the SQLite writer"); + + let result = tokio::time::timeout(Duration::from_secs(2), dispatch) + .await + .expect("canceled submission should finish") + .expect("dispatch task should join"); + assert!(matches!( + result, + RuntimeToolResult::InternalFailure { ref code } if code == "execution_cancelled" + )); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let (approvals, pins) = sqlx::query_as::<_, (i64, i64)>( + "SELECT \ + (SELECT COUNT(*) FROM approvals WHERE execution_id = ?), \ + (SELECT COUNT(*) FROM approval_delivery_pins)", + ) + .bind(execution_id) + .fetch_one(app.pool()) + .await + .expect("canceled submission state should read"); + if approvals == 1 && pins == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("canceled submission ticket should release its delivery pin"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn cancellation_and_approval_claim_have_one_linearized_winner() { + let (_directory, app, upstream_state, upstream_task) = fixture().await; + + let canceled_execution_id = "runtime-cancel-wins"; + let (manager, dispatcher) = execution(&app, canceled_execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let runtime_dispatcher = dispatcher.clone(); + let canceled_execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: canceled_execution_id.to_owned(), + code: "return await tools.runtime.first();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + runtime_cancellation, + ) + .await + }); + let canceled_approval = pending(&app, canceled_execution_id, 1).await.remove(0); + sqlx::query( + "CREATE TRIGGER reject_cancel_winner_cleanup BEFORE UPDATE OF status ON approvals \ + WHEN OLD.execution_id = 'runtime-cancel-wins' AND NEW.status = 'canceled' BEGIN \ + SELECT RAISE(FAIL, 'hold cancellation cleanup'); END", + ) + .execute(app.pool()) + .await + .expect("cancellation cleanup blocker should install"); + cancellation.cancel(); + let canceled_decision = app + .tool_calls() + .decide( + &canceled_approval.id, + "approval-must-lose", + canceled_approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await; + assert!(canceled_decision.is_err()); + sqlx::query("DROP TRIGGER reject_cancel_winner_cleanup") + .execute(app.pool()) + .await + .expect("cancellation cleanup blocker should be removed"); + let canceled = canceled_execution + .await + .expect("canceled execution task should not panic") + .expect_err("cancellation winner should stop the runtime"); + assert_eq!(canceled.code, "execution_cancelled"); + dispatcher.finish().await; + + upstream_state.block_ask.store(true, Ordering::Release); + let claimed_execution_id = "runtime-claim-wins"; + let (manager, dispatcher) = execution(&app, claimed_execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let runtime_dispatcher = dispatcher.clone(); + let claimed_execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: claimed_execution_id.to_owned(), + code: "return await tools.runtime.second();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + runtime_cancellation, + ) + .await + }); + let claimed_approval = pending(&app, claimed_execution_id, 1).await.remove(0); + app.tool_calls() + .decide( + &claimed_approval.id, + "approval-must-win", + claimed_approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should claim execution"); + tokio::time::timeout(Duration::from_secs(2), upstream_state.entered.notified()) + .await + .expect("approved request should reach upstream after claim"); + cancellation.cancel(); + upstream_state.release.notify_one(); + let stopped = claimed_execution + .await + .expect("claimed execution task should not panic") + .expect_err("lost continuation should stop waiting for claimed work"); + assert_eq!(stopped.code, "execution_cancelled"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let detail = app + .tool_calls() + .approvals() + .get_admin(&claimed_approval.id) + .await + .expect("claimed approval should read") + .expect("claimed approval should remain stored"); + if detail.record.status == ApprovalStatus::Succeeded { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("already executing approved work should finish truthfully"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn persistent_cleanup_failure_defers_safely_without_hanging_shutdown() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-persistent-cleanup-failure"; + let (manager, dispatcher) = execution(&app, execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.cancel();".to_owned(), + timeout: Duration::from_secs(5), + }, + dispatcher, + runtime_cancellation, + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + sqlx::query( + "CREATE TRIGGER reject_runtime_cleanup BEFORE UPDATE OF status ON approvals \ + WHEN NEW.status = 'canceled' BEGIN \ + SELECT RAISE(FAIL, 'forced persistent cleanup failure'); END", + ) + .execute(app.pool()) + .await + .expect("persistent cleanup failure trigger should install"); + cancellation.cancel(); + let failure = tokio::time::timeout(Duration::from_secs(2), execution) + .await + .expect("bounded cleanup should let the execution finish") + .expect("execution task should not panic") + .expect_err("canceled execution should fail"); + assert_eq!(failure.code, "execution_cancelled"); + + let decision = app + .tool_calls() + .decide( + &approval.id, + "must-not-approve-lost-continuation", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await; + assert!( + decision.is_err(), + "lost continuation must not become executable" + ); + let stored = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should remain readable") + .expect("approval should remain stored"); + assert_eq!(stored.record.status, ApprovalStatus::Pending); + + upstream_task.abort(); + tokio::time::timeout(Duration::from_secs(2), app.shutdown()) + .await + .expect("persistent cleanup failure must not hang shutdown"); +} + +#[tokio::test] +async fn denial_revocation_cancellation_and_correlation_fail_closed() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + + let execution_id = "runtime-denied"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let denied = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.deny();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + app.tool_calls() + .decide( + &approval.id, + "deny-runtime", + approval.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("denial should succeed"); + let output = denied + .await + .expect("denied task should not panic") + .expect("denial should remain a tool value"); + assert_eq!(output.result["error"]["code"], "approval_denied"); + dispatcher.finish().await; + + let correlation = Arc::new(InvocationToolDispatcher::new( + app.tool_calls().clone(), + InvocationContext { + request_id: "correlation-request".to_owned(), + actor: ToolActor::api_token("runtime-owner", None), + surface: RequestSurface::Gateway, + execution_id: "expected-execution".to_owned(), + }, + )); + let result = correlation + .dispatch( + RuntimeToolCall { + execution_id: "wrong-execution".to_owned(), + worker_generation: 1, + call_id: 1, + path: "runtime.enabled".to_owned(), + arguments: json!({}), + }, + ExecutionCancellation::default(), + ) + .await; + assert!(matches!( + result, + RuntimeToolResult::InternalFailure { ref code } if code == "tool_correlation_failed" + )); + correlation.finish().await; + + let execution_id = "runtime-canceled"; + let (manager, dispatcher) = execution(&app, execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let runtime_dispatcher = dispatcher.clone(); + let canceled = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.cancel();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + runtime_cancellation, + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + cancellation.cancel(); + let failure = canceled + .await + .expect("canceled task should not panic") + .expect_err("canceled execution should fail"); + assert_eq!(failure.code, "execution_cancelled"); + dispatcher.finish().await; + let approval = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read") + .expect("approval should remain stored"); + assert_eq!(approval.record.status, ApprovalStatus::Canceled); + assert_eq!(approval.record.revision, 1); + + let execution_id = "runtime-revoked"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let revoked = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.revoke();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + pending(&app, execution_id, 1).await; + assert!( + app.tool_calls() + .revoke_owner_token("runtime-owner") + .await + .expect("token revocation should succeed") + ); + let output = revoked + .await + .expect("revoked task should not panic") + .expect("revocation should settle the tool promise"); + assert_eq!(output.result["error"]["code"], "approval_canceled"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn execute_api_authenticates_before_body_and_enforces_source_and_time_limits() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("API upstream should bind"); + let upstream_address = listener.local_addr().expect("upstream address should read"); + let upstream_task = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .fallback(upstream) + .with_state(UpstreamState::default()), + ) + .await + .expect("API upstream should serve"); + }); + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open( + AppConfig::new(directory.path().to_path_buf()) + .with_runtime_executable(env!("CARGO_BIN_EXE_executor").into()), + ) + .await + .expect("Executor should open"); + + let oversized = json!({ "code": "x".repeat(2 * 1024 * 1024) }).to_string(); + let unauthenticated = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + oversized, + &[], + ) + .await; + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let setup = api_request( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("setup token should exist"), + "username": "admin", + "password": "correct horse battery staple" + }) + .to_string(), + &[(header::ORIGIN.as_str(), "http://127.0.0.1:4788")], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let login = api_request( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }) + .to_string(), + &[(header::ORIGIN.as_str(), "http://127.0.0.1:4788")], + ) + .await; + assert_eq!(login.status(), StatusCode::OK); + let cookies = response_cookies(&login); + let login_body = api_body(login).await; + let csrf = login_body["csrfToken"] + .as_str() + .expect("setup should return CSRF"); + let token_response = api_request( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Runtime API" }).to_string(), + &[ + (header::COOKIE.as_str(), &cookies), + (header::ORIGIN.as_str(), "http://127.0.0.1:4788"), + ("x-executor-csrf", csrf), + ], + ) + .await; + assert_eq!(token_response.status(), StatusCode::CREATED); + let token_body = api_body(token_response).await; + let token = token_body["token"] + .as_str() + .expect("token should be returned") + .to_owned(); + let token_id = token_body["id"] + .as_str() + .expect("token ID should be returned") + .to_owned(); + let authorization = format!("Bearer {token}"); + + let cookie_only = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return 1" }).to_string(), + &[(header::COOKIE.as_str(), &cookies)], + ) + .await; + assert_eq!(cookie_only.status(), StatusCode::UNAUTHORIZED); + + let invalid_timeout = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return 1", "timeoutMs": 300_001 }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invalid_timeout.status(), StatusCode::BAD_REQUEST); + assert_eq!( + api_body(invalid_timeout).await["error"]["code"], + "invalid_timeout" + ); + + let invalid_json = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + "{".to_owned(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invalid_json.status(), StatusCode::BAD_REQUEST); + let invalid_request_id = invalid_json + .headers() + .get("x-request-id") + .expect("invalid response should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + assert_eq!( + api_body(invalid_json).await["error"]["code"], + "invalid_json" + ); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let recorded = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM request_logs WHERE request_id = ? \ + AND path_snapshot = 'executor.execute' AND error_code = 'invalid_json')", + ) + .bind(&invalid_request_id) + .fetch_one(app.pool()) + .await + .expect("invalid execution log should read"); + if recorded != 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("authenticated invalid JSON should be logged"); + + let source_too_large = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "x".repeat(1024 * 1024 + 1) }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(source_too_large.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + api_body(source_too_large).await["error"]["code"], + "source_too_large" + ); + + let valid = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "emit({phase: 'ready'}); console.log('ok'); return 42" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(valid.status(), StatusCode::OK); + let valid = api_body(valid).await; + assert_eq!(valid["result"], 42); + assert_eq!(valid["emits"][0]["phase"], "ready"); + assert_eq!(valid["console"][0]["message"], "ok"); + assert!(valid["executionId"].as_str().is_some()); + assert_eq!(valid["calls"], json!([])); + + let source = api_request( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Runtime API source", + "preferredSlug": "api", + "spec": { + "type": "inline", + "content": json!({ + "openapi": "3.1.0", + "info": { "title": "Runtime API" }, + "servers": [{ "url": format!("http://{upstream_address}") }], + "paths": { + "/enabled": { + "get": { + "operationId": "enabled", + "security": [{}], + "responses": { "200": { "description": "ok" } } + } + }, + "/ask": { + "post": { + "operationId": "ask", + "security": [{}], + "responses": { "200": { "description": "ok" } } + } + } + } + }).to_string() + }, + "allowPrivateNetwork": true + }) + .to_string(), + &[ + (header::COOKIE.as_str(), &cookies), + (header::ORIGIN.as_str(), "http://127.0.0.1:4788"), + ("x-executor-csrf", csrf), + ], + ) + .await; + assert_eq!( + source.status(), + StatusCode::CREATED, + "{}", + api_body(source).await + ); + + let enabled = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.enabled()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(enabled.status(), StatusCode::OK); + let enabled = api_body(enabled).await; + assert_eq!(enabled["result"]["path"], "/enabled"); + assert_eq!(enabled["calls"].as_array().map(Vec::len), Some(1)); + + let ask_router = app.router(); + let ask_authorization = authorization.clone(); + let ask = tokio::spawn(async move { + api_request( + ask_router, + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.ask()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &ask_authorization)], + ) + .await + }); + let approval = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let mut pending = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + status: Some(ApprovalStatus::Pending), + limit: 100, + ..Default::default() + }) + .await + .expect("API approvals should list") + .items; + if let Some(approval) = pending + .drain(..) + .find(|approval| approval.callable_path_snapshot.ends_with(".ask")) + { + break approval; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("API approval should appear"); + let approved_id = approval.id.clone(); + app.tool_calls() + .decide( + &approval.id, + "approve-api-runtime", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("API approval should succeed"); + let ask = ask.await.expect("Ask HTTP task should not panic"); + assert_eq!(ask.status(), StatusCode::OK); + assert_eq!(api_body(ask).await["result"]["path"], "/ask"); + + let dropped_router = app.router(); + let dropped_authorization = authorization.clone(); + let dropped = tokio::spawn(async move { + api_request( + dropped_router, + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.ask()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &dropped_authorization)], + ) + .await + }); + let dropped_approval = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let pending = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + status: Some(ApprovalStatus::Pending), + limit: 100, + ..Default::default() + }) + .await + .expect("API approvals should list") + .items; + if let Some(approval) = pending + .into_iter() + .find(|approval| approval.id != approved_id) + { + break approval; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped request approval should appear"); + sqlx::query(&format!( + "CREATE TRIGGER reject_dropped_http_cleanup BEFORE UPDATE OF status ON approvals \ + WHEN OLD.execution_id = '{}' AND NEW.status = 'canceled' BEGIN \ + SELECT RAISE(FAIL, 'hold HTTP cancellation cleanup'); END", + dropped_approval.execution_id + )) + .execute(app.pool()) + .await + .expect("HTTP cancellation cleanup blocker should install"); + dropped.abort(); + assert!( + dropped + .await + .expect_err("HTTP execution waiter should be aborted") + .is_cancelled() + ); + let dropped_decision = app + .tool_calls() + .decide( + &dropped_approval.id, + "dropped-request-must-not-execute", + dropped_approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await; + assert!( + dropped_decision.is_err(), + "a disconnected HTTP execution must close its approval gate synchronously" + ); + sqlx::query("DROP TRIGGER reject_dropped_http_cleanup") + .execute(app.pool()) + .await + .expect("HTTP cancellation cleanup blocker should be removed"); + + let revoke_router = app.router(); + let revoke_authorization = authorization.clone(); + let waiting = tokio::spawn(async move { + api_request( + revoke_router, + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.ask()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &revoke_authorization)], + ) + .await + }); + let pending_revoke = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let pending = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + status: Some(ApprovalStatus::Pending), + limit: 100, + ..Default::default() + }) + .await + .expect("API approvals should list") + .items; + if let Some(approval) = pending + .into_iter() + .find(|approval| approval.id != approved_id && approval.id != dropped_approval.id) + { + break approval; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("revoked approval should appear"); + let revoked = api_request( + app.router(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + String::new(), + &[ + (header::COOKIE.as_str(), &cookies), + (header::ORIGIN.as_str(), "http://127.0.0.1:4788"), + ("x-executor-csrf", csrf), + ], + ) + .await; + assert_eq!(revoked.status(), StatusCode::NO_CONTENT); + let waiting = waiting + .await + .expect("revoked execution task should not panic"); + assert_eq!(waiting.status(), StatusCode::CONFLICT); + assert_eq!( + api_body(waiting).await["error"]["code"], + "execution_cancelled" + ); + let pending_revoke = app + .tool_calls() + .approvals() + .get_admin(&pending_revoke.id) + .await + .expect("revoked approval should read") + .expect("revoked approval should remain stored"); + assert_eq!(pending_revoke.record.status, ApprovalStatus::Canceled); + upstream_task.abort(); + app.shutdown().await; +} diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index fe964975c..f56521959 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -5,9 +5,12 @@ import { bulkSetToolModes, createOpenApiSource, createToken, + decideApproval, deleteOpenApiCredentials, + getApproval, getOpenApiCredentials, getBootstrap, + listApprovals, listRequestLogs, listSources, listTokens, @@ -79,6 +82,46 @@ function logFixture() { }; } +function approvalSummaryFixture() { + return { + id: "approval-1", + status: "pending", + revision: 4, + sourceId: "source-1", + toolId: "tool-1", + path: "tools.github.create_issue", + sourceDisplayName: "GitHub", + toolDisplayName: "Create issue", + actorKind: "api_token", + actorId: "token-1", + actorName: "Laptop", + actorLabel: "Laptop", + actorApiTokenId: "token-1", + actorTokenName: "Laptop", + surface: "gateway", + mode: "ask", + provenance: "tool_override", + executionId: "execution-1", + callId: "call-1", + createdAt: 100, + updatedAt: 101, + expiresAt: 700, + decidedAt: null, + startedAt: null, + completedAt: null, + decision: null, + failureCode: null, + }; +} + +function approvalDetailFixture() { + return { + ...approvalSummaryFixture(), + redactedArguments: { title: "Issue title", body: "[REDACTED]" }, + inputSchema: { type: "object" }, + }; +} + const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); describe("dashboard API client", () => { @@ -493,4 +536,118 @@ describe("dashboard API client", () => { expect(result.ok && result.value.configuredSchemes).toEqual([]); }); + + it("lists and reads strict approval DTOs without retaining secret extras", async () => { + const secret = "approval-secret-sentinel"; + const list = await listApprovals( + { status: "pending", cursor: "older/page", limit: 50 }, + async (input) => { + expect(String(input)).toBe("/api/v1/approvals?limit=50&status=pending&cursor=older%2Fpage"); + return Response.json({ + items: [ + { + ...approvalSummaryFixture(), + sourceDisplayName: null, + toolDisplayName: null, + actorKind: "system", + actorId: "local_cli", + actorName: null, + actorLabel: "Local CLI", + actorApiTokenId: null, + actorTokenName: null, + rawArguments: { password: secret }, + encryptedArguments: secret, + credential: secret, + }, + ], + nextCursor: "next", + internalKey: secret, + }); + }, + ); + const detail = await getApproval("approval/1", async (input) => { + expect(String(input)).toBe("/api/v1/approvals/approval%2F1"); + return Response.json({ + ...approvalDetailFixture(), + rawArguments: { password: secret }, + encryptedArguments: secret, + result: secret, + }); + }); + + expect(list.ok).toBe(true); + expect(detail.ok).toBe(true); + expect(JSON.stringify(list)).not.toContain(secret); + expect(JSON.stringify(detail)).not.toContain(secret); + expect(list.ok && list.value.items[0]?.sourceDisplayName).toBeNull(); + expect(list.ok && list.value.items[0]?.toolDisplayName).toBeNull(); + expect(list.ok && list.value.items[0]?.actorTokenName).toBeNull(); + expect(list.ok && list.value.items[0]?.actorKind).toBe("system"); + expect(list.ok && list.value.items[0]?.actorLabel).toBe("Local CLI"); + expect(list.ok && list.value.items[0]?.actorApiTokenId).toBeNull(); + expect(detail.ok && detail.value.redactedArguments).toEqual({ + title: "Issue title", + body: "[REDACTED]", + }); + }); + + it("omits the approval status parameter for an all-status list", async () => { + await listApprovals({ status: null, cursor: null, limit: 50 }, async (input) => { + expect(String(input)).toBe("/api/v1/approvals?limit=50"); + return Response.json({ items: [], nextCursor: null }); + }); + }); + + it("sends approval decisions once with the viewed CAS revision", async () => { + document.cookie = "executor_csrf=approval_csrf; Path=/"; + let calls = 0; + let body: unknown; + let csrf: string | null = null; + const result = await decideApproval("approval/1", "approve", 4, async (input, init) => { + calls += 1; + expect(String(input)).toBe("/api/v1/approvals/approval%2F1/decision"); + expect(init?.method).toBe("POST"); + body = decodeJson(String(init?.body)); + csrf = new Headers(init?.headers).get("x-executor-csrf"); + return Response.json({ + ...approvalDetailFixture(), + status: "approved", + revision: 5, + decidedAt: 150, + }); + }); + + expect(calls).toBe(1); + expect(body).toEqual({ decision: "approve", expectedRevision: 4 }); + expect(csrf).toBe("approval_csrf"); + expect(result.ok && result.value.status).toBe("approved"); + }); + + it("preserves an approval conflict for refetch and review without retrying", async () => { + let calls = 0; + const result = await decideApproval("approval-1", "deny", 4, async () => { + calls += 1; + return Response.json( + { + error: { + code: "revision_conflict", + message: "The approval changed.", + requestId: "request-conflict", + }, + }, + { status: 409 }, + ); + }); + + expect(calls).toBe(1); + expect(result).toEqual({ + ok: false, + error: new ApiError({ + code: "revision_conflict", + displayMessage: "The approval changed.", + requestId: "request-conflict", + status: 409, + }), + }); + }); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 72b1f808e..f792b3dd3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -192,6 +192,60 @@ const RequestLogPageSchema = Schema.Struct({ nextCursor: Schema.NullOr(Schema.String), }); +const ApprovalStatusSchema = Schema.Literals([ + "pending", + "approved", + "executing", + "succeeded", + "failed", + "denied", + "canceled", + "expired", + "stale", + "interrupted", +]); + +const ApprovalSummarySchema = Schema.Struct({ + id: Schema.String, + status: ApprovalStatusSchema, + revision: Schema.Number, + sourceId: Schema.String, + toolId: Schema.String, + path: Schema.String, + sourceDisplayName: Schema.NullOr(Schema.String), + toolDisplayName: Schema.NullOr(Schema.String), + actorKind: Schema.Literals(["api_token", "admin", "system"]), + actorId: Schema.String, + actorName: Schema.NullOr(Schema.String), + actorLabel: Schema.String, + actorApiTokenId: Schema.NullOr(Schema.String), + actorTokenName: Schema.NullOr(Schema.String), + surface: Schema.Literals(["gateway", "cli", "mcp"]), + mode: Schema.Literal("ask"), + provenance: ModeProvenanceSchema, + executionId: Schema.String, + callId: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, + expiresAt: Schema.Number, + decidedAt: Schema.NullOr(Schema.Number), + startedAt: Schema.NullOr(Schema.Number), + completedAt: Schema.NullOr(Schema.Number), + decision: Schema.NullOr(Schema.Literals(["approve", "deny"])), + failureCode: Schema.NullOr(Schema.String), +}); + +const ApprovalDetailSchema = Schema.Struct({ + ...ApprovalSummarySchema.fields, + redactedArguments: Schema.Unknown, + inputSchema: Schema.Unknown, +}); + +const ApprovalPageSchema = Schema.Struct({ + items: Schema.Array(ApprovalSummarySchema), + nextCursor: Schema.NullOr(Schema.String), +}); + const ErrorEnvelopeSchema = Schema.Struct({ error: Schema.Struct({ code: Schema.String, @@ -216,6 +270,11 @@ export type ToolPage = typeof ToolPageSchema.Type; export type BulkToolModeResult = typeof BulkToolModeResultSchema.Type; export type RequestLog = typeof RequestLogSchema.Type; export type RequestLogPage = typeof RequestLogPageSchema.Type; +export type ApprovalStatus = typeof ApprovalStatusSchema.Type; +export type ApprovalSummary = typeof ApprovalSummarySchema.Type; +export type ApprovalDetail = typeof ApprovalDetailSchema.Type; +export type ApprovalPage = typeof ApprovalPageSchema.Type; +export type ApprovalDecision = "approve" | "deny"; export class ApiError extends Schema.TaggedErrorClass()("ApiError", { code: Schema.String, @@ -255,6 +314,10 @@ const decodeRequestLog = Schema.decodeUnknownOption(Schema.fromJsonString(Reques const decodeRequestLogPage = Schema.decodeUnknownOption( Schema.fromJsonString(RequestLogPageSchema), ); +const decodeApprovalDetail = Schema.decodeUnknownOption( + Schema.fromJsonString(ApprovalDetailSchema), +); +const decodeApprovalPage = Schema.decodeUnknownOption(Schema.fromJsonString(ApprovalPageSchema)); const decodeErrorEnvelope = Schema.decodeUnknownOption(Schema.fromJsonString(ErrorEnvelopeSchema)); export async function getBootstrap(fetcher: Fetcher = fetch, signal?: AbortSignal) { @@ -591,6 +654,57 @@ export async function getRequestLog( return decodeResponse(response.value, decodeRequestLog); } +export async function listApprovals( + filters: { + readonly status: ApprovalStatus | null; + readonly cursor: string | null; + readonly limit: number; + }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const parameters = new URLSearchParams({ limit: String(filters.limit) }); + if (filters.status !== null) parameters.set("status", filters.status); + if (filters.cursor !== null) parameters.set("cursor", filters.cursor); + const response = await request(`/api/v1/approvals?${parameters}`, { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeApprovalPage); +} + +export async function getApproval( + approvalId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/approvals/${encodeURIComponent(approvalId)}`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeApprovalDetail); +} + +export async function decideApproval( + approvalId: string, + decision: ApprovalDecision, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/approvals/${encodeURIComponent(approvalId)}/decision`, + { + method: "POST", + body: JSON.stringify({ decision, expectedRevision }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeApprovalDetail); +} + async function request(path: string, init: RequestInit, fetcher: Fetcher, includeCsrf = true) { if (!path.startsWith("/api/")) { return failure( diff --git a/web/src/lib/approval-state.test.ts b/web/src/lib/approval-state.test.ts new file mode 100644 index 000000000..5d5043674 --- /dev/null +++ b/web/src/lib/approval-state.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ApprovalDetail } from "$lib/api"; +import { + approvalCountdown, + approvalDecisionCopy, + approvalStatusLabel, + canDecideApproval, + createApprovalPoller, + decisionResultScope, + focusTargetAfterDecision, +} from "./approval-state"; + +function fixture(overrides: Partial = {}): ApprovalDetail { + return { + id: "approval-1", + status: "pending", + revision: 4, + createdAt: 100, + updatedAt: 101, + expiresAt: 200, + decidedAt: null, + completedAt: null, + sourceId: "source-1", + toolId: "tool-1", + path: "tools.github.create_issue", + sourceDisplayName: "GitHub", + toolDisplayName: "Create issue", + actorKind: "api_token", + actorId: "token-1", + actorName: "Laptop", + actorLabel: "Laptop", + actorApiTokenId: "token-1", + actorTokenName: "Laptop", + surface: "gateway", + mode: "ask", + provenance: "tool_override", + executionId: "execution-1", + callId: "call-1", + redactedArguments: { title: "Safe title", token: "[REDACTED]" }, + inputSchema: { type: "object" }, + failureCode: null, + startedAt: null, + decision: null, + ...overrides, + }; +} + +describe("approval UI state", () => { + it("labels every terminal and uncertain state honestly", () => { + expect(approvalStatusLabel("stale")).toBe("Stale"); + expect(approvalStatusLabel("interrupted")).toBe("Interrupted"); + expect(approvalStatusLabel("canceled")).toBe("Canceled"); + }); + + it("only permits decisions while the approval is pending and unexpired", () => { + expect(canDecideApproval(fixture(), 150_000)).toBe(true); + expect(canDecideApproval(fixture({ status: "approved" }), 150_000)).toBe(false); + expect(canDecideApproval(fixture({ status: "stale" }), 150_000)).toBe(false); + expect(canDecideApproval(fixture(), 200_000)).toBe(false); + }); + + it("computes countdown text with an injected clock", () => { + expect(approvalCountdown(200, 150_000)).toBe("50s remaining"); + expect(approvalCountdown(250, 150_000)).toBe("2m remaining"); + expect(approvalCountdown(150, 150_000)).toBe("Expired"); + }); + + it("restates the tool and side-effect boundary in confirmations", () => { + expect(approvalDecisionCopy(fixture(), "approve")).toContain( + "stored original arguments, including values hidden from this structural preview", + ); + expect(approvalDecisionCopy(fixture(), "approve")).toContain( + "This may cause side effects in GitHub.", + ); + expect(approvalDecisionCopy(fixture(), "deny")).toContain("waiting caller will not run"); + }); + + it("uses safe confirmation fallbacks when snapshot names are unavailable", () => { + const approval = fixture({ + sourceDisplayName: null, + toolDisplayName: null, + }); + expect(approvalDecisionCopy(approval, "approve")).toContain( + "Approve one execution of tools.github.create_issue?", + ); + expect(approvalDecisionCopy(approval, "approve")).toContain("the connected source"); + }); + + it("chooses a stable focus target after a decision removes a pending row", () => { + const first = fixture(); + const second = fixture({ id: "approval-2" }); + const third = fixture({ id: "approval-3" }); + expect(focusTargetAfterDecision([first, second, third], second.id)).toBe( + "inspect-approval-approval-3", + ); + expect(focusTargetAfterDecision([first], first.id)).toBe("approvals-heading"); + }); + + it("rejects a late decision result for a newer detail or list identity", () => { + expect( + decisionResultScope({ + submittedApprovalId: "approval-a", + submittedListKey: "pending:first", + currentApprovalId: "approval-b", + currentListKey: "pending:first", + }), + ).toEqual({ sameDetail: false, sameList: true }); + expect( + decisionResultScope({ + submittedApprovalId: "approval-a", + submittedListKey: "pending:first", + currentApprovalId: "approval-a", + currentListKey: "denied:first", + }), + ).toEqual({ sameDetail: true, sameList: false }); + }); + + it("polls only completed resources and cancels the scheduled retry on disposal", () => { + let listLoading = true; + let detailLoading = true; + let listRefreshes = 0; + let detailRefreshes = 0; + let nextHandle = 0; + const scheduled = new Map void>(); + const canceled: number[] = []; + const dispose = createApprovalPoller({ + schedule: (callback) => { + const handle = ++nextHandle; + scheduled.set(handle, callback); + return handle; + }, + cancel: (handle) => canceled.push(handle), + isVisible: () => true, + isListLoading: () => listLoading, + hasDetail: () => true, + isDetailLoading: () => detailLoading, + refreshList: () => (listRefreshes += 1), + refreshDetail: () => (detailRefreshes += 1), + }); + + scheduled.get(1)?.(); + expect([listRefreshes, detailRefreshes]).toEqual([0, 0]); + listLoading = false; + detailLoading = false; + scheduled.get(2)?.(); + expect([listRefreshes, detailRefreshes]).toEqual([1, 1]); + dispose(); + expect(canceled).toEqual([3]); + scheduled.get(3)?.(); + expect([listRefreshes, detailRefreshes]).toEqual([1, 1]); + }); +}); diff --git a/web/src/lib/approval-state.ts b/web/src/lib/approval-state.ts new file mode 100644 index 000000000..95295ea70 --- /dev/null +++ b/web/src/lib/approval-state.ts @@ -0,0 +1,89 @@ +import type { ApprovalDecision, ApprovalDetail, ApprovalStatus, ApprovalSummary } from "$lib/api"; + +export const approvalStatuses = [ + "pending", + "approved", + "executing", + "succeeded", + "failed", + "denied", + "expired", + "canceled", + "stale", + "interrupted", +] as const satisfies readonly ApprovalStatus[]; + +export function approvalStatusLabel(status: ApprovalStatus) { + return `${status[0].toUpperCase()}${status.slice(1)}`; +} + +export function canDecideApproval(approval: ApprovalSummary, now: number) { + return approval.status === "pending" && approval.expiresAt > Math.floor(now / 1000); +} + +export function approvalCountdown(expiresAt: number, now: number) { + const remaining = expiresAt - Math.floor(now / 1000); + if (remaining <= 0) return "Expired"; + if (remaining < 60) return `${remaining}s remaining`; + const minutes = Math.ceil(remaining / 60); + return `${minutes}m remaining`; +} + +export function approvalDecisionCopy(approval: ApprovalDetail, decision: ApprovalDecision) { + const tool = approval.toolDisplayName ?? approval.path; + const source = approval.sourceDisplayName ?? "the connected source"; + if (decision === "approve") { + return `Approve one execution of ${tool}? Executor will use the stored original arguments, including values hidden from this structural preview. This may cause side effects in ${source}.`; + } + return `Deny this execution of ${tool}? The waiting caller will not run this request.`; +} + +export function focusTargetAfterDecision(items: readonly ApprovalSummary[], approvalId: string) { + const index = items.findIndex((approval) => approval.id === approvalId); + const next = items[index + 1] ?? items[index - 1]; + return next === undefined ? "approvals-heading" : `inspect-approval-${next.id}`; +} + +export function decisionResultScope(input: { + readonly submittedApprovalId: string; + readonly submittedListKey: string; + readonly currentApprovalId: string | null; + readonly currentListKey: string; +}) { + return { + sameDetail: input.currentApprovalId === input.submittedApprovalId, + sameList: input.currentListKey === input.submittedListKey, + }; +} + +export function createApprovalPoller( + options: { + readonly schedule: (callback: () => void, delay: number) => number; + readonly cancel: (handle: number) => void; + readonly isVisible: () => boolean; + readonly isListLoading: () => boolean; + readonly hasDetail: () => boolean; + readonly isDetailLoading: () => boolean; + readonly refreshList: () => void; + readonly refreshDetail: () => void; + }, + interval = 5_000, +) { + let disposed = false; + let handle = 0; + + function poll() { + if (disposed) return; + if (options.isVisible()) { + if (!options.isListLoading()) options.refreshList(); + if (options.hasDetail() && !options.isDetailLoading()) options.refreshDetail(); + } + handle = options.schedule(poll, interval); + } + + handle = options.schedule(poll, interval); + return () => { + disposed = true; + options.cancel(handle); + }; +} diff --git a/web/src/lib/approval-url.test.ts b/web/src/lib/approval-url.test.ts new file mode 100644 index 000000000..8ef839fa8 --- /dev/null +++ b/web/src/lib/approval-url.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import { approvalsListKey, approvalsUrl, parseApprovalsUrl } from "./approval-url"; + +describe("approval URL state", () => { + it("defaults invalid and missing status filters to pending", () => { + expect(parseApprovalsUrl(new URLSearchParams())).toEqual({ + status: "pending", + cursor: null, + approval: null, + }); + expect(parseApprovalsUrl(new URLSearchParams("status=surprise&cursor=&approval="))).toEqual({ + status: "pending", + cursor: null, + approval: null, + }); + expect(parseApprovalsUrl(new URLSearchParams("status=active"))).toEqual({ + status: "pending", + cursor: null, + approval: null, + }); + }); + + it("keeps status, pagination, and detail selection bookmarkable", () => { + const state = parseApprovalsUrl( + new URLSearchParams("status=denied&cursor=older-page&approval=approval-1"), + ); + + expect(state).toEqual({ + status: "denied", + cursor: "older-page", + approval: "approval-1", + }); + expect(approvalsUrl(state, { approval: null })).toBe( + "/approvals?status=denied&cursor=older-page", + ); + expect(approvalsUrl(state, { status: "pending", cursor: null })).toBe( + "/approvals?approval=approval-1", + ); + }); + + it("excludes detail selection from the list request identity", () => { + const first = parseApprovalsUrl( + new URLSearchParams("status=failed&cursor=older&approval=approval-a"), + ); + const second = parseApprovalsUrl( + new URLSearchParams("status=failed&cursor=older&approval=approval-b"), + ); + + expect(approvalsListKey(first)).toBe(approvalsListKey(second)); + expect(approvalsListKey(first)).not.toBe( + approvalsListKey({ status: "pending", cursor: "older", approval: "approval-a" }), + ); + }); +}); diff --git a/web/src/lib/approval-url.ts b/web/src/lib/approval-url.ts new file mode 100644 index 000000000..2aa6c080c --- /dev/null +++ b/web/src/lib/approval-url.ts @@ -0,0 +1,53 @@ +import type { ApprovalStatus } from "$lib/api"; + +const statuses = new Set([ + "pending", + "approved", + "executing", + "succeeded", + "failed", + "denied", + "expired", + "canceled", + "stale", + "interrupted", +]); + +export type ApprovalStatusFilter = ApprovalStatus | "all"; + +export type ApprovalsUrlState = { + readonly status: ApprovalStatusFilter; + readonly cursor: string | null; + readonly approval: string | null; +}; + +export function parseApprovalsUrl(parameters: URLSearchParams): ApprovalsUrlState { + const rawStatus = parameters.get("status"); + return { + status: rawStatus === "all" || isApprovalStatus(rawStatus) ? rawStatus : "pending", + cursor: nonEmpty(parameters.get("cursor")), + approval: nonEmpty(parameters.get("approval")), + }; +} + +export function approvalsUrl(state: ApprovalsUrlState, updates: Partial = {}) { + const next = { ...state, ...updates }; + const parameters = new URLSearchParams(); + if (next.status !== "pending") parameters.set("status", next.status); + if (next.cursor) parameters.set("cursor", next.cursor); + if (next.approval) parameters.set("approval", next.approval); + const search = parameters.toString(); + return search ? `/approvals?${search}` : "/approvals"; +} + +export function approvalsListKey(state: ApprovalsUrlState) { + return JSON.stringify([state.status, state.cursor]); +} + +function isApprovalStatus(value: string | null): value is ApprovalStatus { + return value !== null && statuses.has(value as ApprovalStatus); +} + +function nonEmpty(value: string | null) { + return value === null || value === "" ? null : value; +} diff --git a/web/src/routes/approvals/+page.svelte b/web/src/routes/approvals/+page.svelte index a1605331f..aee2687a6 100644 --- a/web/src/routes/approvals/+page.svelte +++ b/web/src/routes/approvals/+page.svelte @@ -1,15 +1,649 @@ - - + + + +

{announcement}

+ +
+
+

One request at a time

+

Approval is exact and single-use

+
+

+ Approving permits only this invocation. The argument preview is structural and redacted; + hidden original values are what Executor will run. A tool, credential, or policy change makes + the request stale instead of silently applying your decision elsewhere. +

+
+ +
+
+

Decision queue

+

Approval requests

+
+ +
+ {#if resource.loading}Refreshing...{/if} + +
+
+ + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded approval page read-only while Executor reconnects. + +
+ {:else if resource.error !== null} +
+ + +
+ {/if} + +
+ {#if resource.data === null && resource.loading} +
Loading approvals...
+ {:else if resource.data !== null && resource.data.items.length === 0} +
+

+ {urlState.status === "pending" + ? "No tool calls are waiting for approval." + : "No approvals match this status on this page."} +

+ {#if urlState.cursor !== null || urlState.status !== "pending"} + Return to pending approvals + {/if} +
+ {:else if resource.data !== null} +
+ {#each resource.data.items as approval (approval.id)} +
+
+
+ + {approvalStatusLabel(approval.status)} + +

{approval.toolDisplayName ?? approval.path}

+ {approval.path} +
+
+ {formatAge(approval.createdAt)} + + {approval.status === "pending" + ? approvalCountdown(approval.expiresAt, now) + : `Updated ${formatAge(approval.updatedAt)}`} + +
+
+
+
+
Source
+
{approval.sourceDisplayName ?? "Unavailable source"}
+
+
+
Caller
+
{approval.actorLabel}
+ {approval.surface} +
+
+
Ask rule
+
{provenanceLabel(approval.provenance)}
+
+
+ Review request +
+ {/each} +
+ + {/if} +
+ + {#if urlState.approval !== null} + + {/if}
diff --git a/web/src/styles.css b/web/src/styles.css index 8685137da..f00b0b773 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -883,7 +883,7 @@ td { } .health-pill.healthy, -.outcome-pill:not(.error) { +.outcome-pill.success { border-color: #315b50; color: #75d5b3; background: #11231f; @@ -902,6 +902,18 @@ td { background: #252015; } +.outcome-pill.active { + border-color: #485b96; + color: #b8c4ff; + background: #171d36; +} + +.outcome-pill.warning { + border-color: #675b3e; + color: #dccb9a; + background: #211d14; +} + .source-stats, .detail-list { display: grid; @@ -1204,6 +1216,168 @@ td code { line-height: 1.55; } +.approval-explainer, +.approval-filters { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: center; + padding: 1.2rem 1.4rem; +} + +.approval-explainer h2, +.approval-filters h2 { + margin: 0.3rem 0 0; +} + +.approval-explainer > p { + max-width: 650px; + margin: 0; + color: #aab4c5; + line-height: 1.55; +} + +.approval-filters label { + min-width: 180px; + margin-left: auto; +} + +.approval-list { + display: grid; +} + +.approval-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr) auto; + gap: 1.2rem; + align-items: center; + padding: 1.2rem 1.4rem; + border-bottom: 1px solid #202738; +} + +.approval-row-main { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + min-width: 0; +} + +.approval-row-main h3 { + margin: 0.55rem 0 0.25rem; + color: #eef1f7; + font-size: 0.95rem; +} + +.approval-row-main code { + overflow-wrap: anywhere; + color: #9da8bb; + font-size: 0.72rem; +} + +.approval-timing { + display: grid; + flex: 0 0 auto; + gap: 0.2rem; + color: #d4dbe7; + font-size: 0.75rem; + text-align: right; +} + +.approval-timing small { + color: #a8b3c7; +} + +.approval-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.6rem; + margin: 0; +} + +.approval-summary div { + min-width: 0; + border-left: 2px solid #303a51; + padding-left: 0.65rem; +} + +.approval-summary dt { + color: #8f9bb1; + font-size: 0.62rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.approval-summary dd { + overflow-wrap: anywhere; + margin: 0.25rem 0 0; + color: #d7dde8; + font-size: 0.75rem; +} + +.approval-summary small { + display: block; + margin-top: 0.2rem; + color: #929eb2; + font-size: 0.66rem; +} + +.approval-detail-heading { + display: flex; + gap: 0.8rem; + align-items: center; +} + +.approval-detail-heading code { + overflow-wrap: anywhere; + color: #d5dbe8; +} + +.approval-detail-heading p { + margin: 0.25rem 0 0; + color: #9ca7ba; + font-size: 0.76rem; +} + +.approval-arguments, +.approval-decision { + display: grid; + gap: 0.8rem; + border: 1px solid #293247; + border-radius: 12px; + padding: 1rem; + background: #0d131d; +} + +.approval-arguments h3, +.approval-confirm h3 { + margin: 0; +} + +.approval-arguments p, +.approval-decision p, +.approval-confirm p { + margin: 0; + color: #a2adbf; + font-size: 0.78rem; + line-height: 1.55; +} + +.approval-arguments pre { + max-height: 360px; +} + +.approval-decision { + border-color: #465174; + background: #111728; +} + +.approval-confirm { + display: grid; + gap: 0.8rem; +} + @media (max-width: 820px) { .app-frame { display: block; @@ -1250,6 +1424,25 @@ td code { flex-direction: column; } + .approval-explainer, + .approval-filters { + align-items: flex-start; + flex-direction: column; + } + + .approval-filters label { + width: 100%; + margin-left: 0; + } + + .approval-row { + grid-template-columns: 1fr; + } + + .approval-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .mode-semantics { grid-template-columns: 1fr; } @@ -1427,6 +1620,10 @@ td code { grid-template-columns: 1fr; } + .approval-summary { + grid-template-columns: 1fr; + } + .pagination { grid-template-columns: 1fr; } From 8a99f986b126ee420fc67d0cab3c4e494fe4d4b3 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 24 Jun 2026 02:41:58 -0700 Subject: [PATCH 05/24] feat: add MCP gateway and CLI workflows --- Cargo.lock | 164 ++ Cargo.toml | 3 +- docs/architecture.md | 59 + docs/cli.md | 75 + docs/mcp.md | 408 +++ src/api.rs | 8 + src/api/catalog.rs | 6 +- src/api/protocols.rs | 120 +- src/catalog/mod.rs | 41 + src/catalog/store.rs | 766 ++++- src/cli/client.rs | 656 +++++ src/cli/input.rs | 125 + src/cli/mcp_bridge.rs | 840 ++++++ src/cli/mod.rs | 447 +++ src/cli/output.rs | 145 + src/cli/terminal.rs | 42 + src/database.rs | 2 + src/invocation/idempotency.rs | 80 + src/invocation/mod.rs | 2091 +++++++++++++- src/lib.rs | 79 +- src/main.rs | 89 +- src/mcp/discovery/mod.rs | 531 ++++ src/mcp/discovery/tests.rs | 566 ++++ src/mcp/downstream/mod.rs | 2005 +++++++++++++ src/mcp/downstream/tests.rs | 1210 ++++++++ src/mcp/manager.rs | 1194 ++++++++ src/mcp/mod.rs | 4 + src/mcp/upstream/http/mod.rs | 1450 ++++++++++ src/mcp/upstream/http/tests.rs | 1163 ++++++++ src/mcp/upstream/mod.rs | 2 + src/mcp/upstream/stdio/mod.rs | 2784 ++++++++++++++++++ src/outbound.rs | 158 +- src/protocols/mcp.rs | 3518 +++++++++++++++++++++++ src/protocols/mod.rs | 185 +- tests/catalog.rs | 875 +++++- tests/gateway_sources.rs | 284 ++ tests/outbound.rs | 87 + web/src/lib/McpCredentialEditor.svelte | 383 +++ web/src/lib/McpCredentialEditor.test.ts | 197 ++ web/src/lib/McpHttpSourceForm.svelte | 288 ++ web/src/lib/McpHttpSourceForm.test.ts | 146 + web/src/lib/McpStdioSourceForm.svelte | 302 ++ web/src/lib/McpStdioSourceForm.test.ts | 182 ++ web/src/lib/api.test.ts | 233 +- web/src/lib/api.ts | 180 +- web/src/lib/mcp-source-state.test.ts | 185 ++ web/src/lib/mcp-source-state.ts | 201 ++ web/src/routes/sources/+page.svelte | 460 +-- 48 files changed, 24569 insertions(+), 450 deletions(-) create mode 100644 docs/cli.md create mode 100644 docs/mcp.md create mode 100644 src/cli/client.rs create mode 100644 src/cli/input.rs create mode 100644 src/cli/mcp_bridge.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/output.rs create mode 100644 src/cli/terminal.rs create mode 100644 src/mcp/discovery/mod.rs create mode 100644 src/mcp/discovery/tests.rs create mode 100644 src/mcp/downstream/mod.rs create mode 100644 src/mcp/downstream/tests.rs create mode 100644 src/mcp/manager.rs create mode 100644 src/mcp/mod.rs create mode 100644 src/mcp/upstream/http/mod.rs create mode 100644 src/mcp/upstream/http/tests.rs create mode 100644 src/mcp/upstream/mod.rs create mode 100644 src/mcp/upstream/stdio/mod.rs create mode 100644 src/protocols/mcp.rs create mode 100644 tests/gateway_sources.rs create mode 100644 web/src/lib/McpCredentialEditor.svelte create mode 100644 web/src/lib/McpCredentialEditor.test.ts create mode 100644 web/src/lib/McpHttpSourceForm.svelte create mode 100644 web/src/lib/McpHttpSourceForm.test.ts create mode 100644 web/src/lib/McpStdioSourceForm.svelte create mode 100644 web/src/lib/McpStdioSourceForm.test.ts create mode 100644 web/src/lib/mcp-source-state.test.ts create mode 100644 web/src/lib/mcp-source-state.ts diff --git a/Cargo.lock b/Cargo.lock index 707dbfdde..9b0d766c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[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" @@ -359,6 +368,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -454,6 +475,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cow-utils" version = "0.1.3" @@ -692,6 +719,7 @@ dependencies = [ "percent-encoding", "rand 0.8.6", "reqwest", + "rmcp", "rquickjs", "serde", "serde_json", @@ -795,6 +823,21 @@ dependencies = [ "num", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -839,6 +882,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -857,8 +911,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1106,6 +1162,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -2459,6 +2539,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +dependencies = [ + "async-trait", + "chrono", + "futures", + "pin-project-lite", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "ropey" version = "1.6.1" @@ -3186,6 +3284,19 @@ dependencies = [ "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" @@ -3588,12 +3699,65 @@ dependencies = [ "wasite", ] +[[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-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.48.0" diff --git a/Cargo.toml b/Cargo.toml index 726825e30..a3bf125c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ oxc = { version = "=0.137.0", default-features = false, features = ["semantic", percent-encoding = "2" rand = "0.8.5" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +rmcp = { version = "=1.8.0", default-features = false } rquickjs = { version = "=0.12.0", default-features = false, features = ["std", "futures"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -34,7 +35,7 @@ sqlx = { version = "0.8", default-features = false, features = [ ] } subtle = "2" thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "fs", "sync", "process", "io-util"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "fs", "sync", "process", "io-util", "io-std"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" diff --git a/docs/architecture.md b/docs/architecture.md index 80b89e6e0..b96c3efc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -312,6 +312,65 @@ metadata. It does not yet claim a managed OAuth flow. State and PKCE handling, browser callbacks, authorization-code exchange, refresh-token rotation, and provider error recovery remain part of the dedicated OAuth slice. +## MCP transport boundary + +Executor exposes one stateful Streamable HTTP MCP endpoint at `/mcp`. It +requires dashboard API-token bearer authentication and protocol version +`2025-11-25`. A session belongs to its creating token and is removed when that +token is revoked. Explicit deletion, token revocation, and lazy idle-session +expiry terminate the session, cancel its scoped waits, and prevent a pending +Ask released by that termination from executing later. The MCP surface +intentionally exposes five stable virtual tools, `execute`, `call`, `search`, +`describe`, and `sources`, instead of mirroring an unbounded upstream catalog +into `tools/list`. For virtual-tool invocations, JSON-RPC request IDs provide +the idempotency and cancellation correlation. Downstream GET streaming is +intentionally unavailable and returns 405 after session validation. Body +admission and detached execution each have independent instance-wide and +per-token concurrency caps, leaving cancellation handling separate from tool +execution admission. Pending Ask calls follow the durable ten-minute approval +TTL with expiry settlement checked at intervals of at most 30 seconds. They do +not use an independent MCP transport wait timeout. + +The Rust package pins `rmcp` exactly at `1.8.0` for MCP model types. Executor +owns the HTTP and stdio transport implementations so protocol handling remains +inside the hardened outbound, process, authentication, and lifecycle +boundaries. + +Upstream MCP sources use the same catalog and invocation boundary as OpenAPI. +Streamable HTTP endpoints use the hardened outbound client, while stdio sources +can select only administrator-approved templates loaded at server startup. +Executables, arguments, working directories, and static environment values are +not accepted from source API requests. Only declared secret environment fields +can be supplied through the encrypted source credential envelope. + +Successful creation with complete credentials and successful refresh complete +initialization and bounded paginated discovery before an atomic catalog commit. +Required-secret stdio creation may instead commit the documented deferred empty +source without starting the child. Upstreams that advertise +`tools.listChanged` receive one coalescing watcher per source. Watchers are +restored on startup, replaced after relevant changes, stopped before deletion, +and drained during shutdown. Candidate HTTP and stdio credentials are used for +discovery before one transaction replaces the encrypted credential and catalog. +An HTTP notification-stream GET that returns 405 is treated as permanently +unsupported only after the current revision-fenced reconciliation commits. The +watcher then exits without reconnecting, so later catalog changes require a +manual refresh. + +Clearing HTTP authentication tries anonymous discovery; clearing required stdio +secrets instead retains the last catalog and marks health unknown. A zero-secret +stdio template treats its empty overlay as a complete credential and refreshes +normally. + +Imported MCP tools are intrinsically `enabled` only when the upstream explicitly +marks them read-only without marking them destructive. Other MCP tools are +intrinsically `ask`. Administrator source and tool overrides still use the global +catalog precedence rules. Invocation creates a fresh upstream connection, +performs one tool call, and closes it. Outcome-unknown tool calls are never +automatically replayed. Long-lived HTTP notification streams have a five-minute +idle-read timeout plus per-event and parser-state bounds, not a fixed lifetime. +The complete configuration, API payloads, lifecycle, and limit contract is +documented in [MCP support](mcp.md). + Audit history is bounded to the most recently inserted 10,000 events for a single-user instance. Each event's serialized metadata is capped at 64 KiB. Insertion and oldest-insertion compaction happen in the same catalog diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 000000000..2807bccbd --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,75 @@ +# Rust CLI + +The `executor` binary is both the self-hosted server and its local client. Client +commands connect to a running server. They do not open the SQLite database or +start another server. + +## Connection + +The default server is `http://127.0.0.1:4788`. Override it with +`--base-url` or `EXECUTOR_BASE_URL`. Create an API token in the dashboard and +provide it through `EXECUTOR_API_TOKEN`: + +```sh +export EXECUTOR_API_TOKEN='copy-the-token-from-the-dashboard' +executor tools search calendar +``` + +You can also use `--api-token`, but the environment variable avoids placing a +secret in shell history or process arguments. Tokens are sent only in the +Authorization header. Executor never puts them in URLs. + +Plain HTTP is accepted only for localhost and literal loopback addresses. Use +HTTPS for another host. `--allow-insecure-http` is an explicit escape hatch for +a trusted network where transport security is handled elsewhere. + +Use `--json` for machine-readable output. Errors and approval instructions go +to stderr, leaving stdout available for JSON and MCP protocol messages. + +## Tools + +Search the enabled global catalog: + +```sh +executor tools search issue +executor tools search issue --namespace github --limit 25 +executor tools describe github.issues_create +executor tools sources +``` + +Call a tool with a dotted path or two path segments. Arguments must be one JSON +object. Prefix a filename with `@` to read the object from disk. + +```sh +executor call github.issues_create '{"owner":"acme","repo":"app","title":"Bug"}' +executor call github issues_create @arguments.json +``` + +Each call gets one idempotency key that remains stable across safe HTTP retries. An +Ask-mode tool prints and opens the clean dashboard approval URL, then polls as +the owning API token until the approval completes. Press Ctrl-C to request +cancellation. + +## MCP stdio bridge + +Configure a local MCP client to launch: + +```sh +executor mcp +``` + +The bridge forwards newline-delimited MCP JSON-RPC over stdio to the running +authenticated `/mcp` endpoint. It preserves the server session, writes only MCP +messages to stdout, and closes the session on EOF or Ctrl-C. The server must +already be running. + +## Dashboard + +Open the dashboard without putting credentials in the URL: + +```sh +executor open +``` + +This uses `open` on macOS and `xdg-open` on Linux. If no graphical opener is +available, the CLI prints the clean URL so it can be copied manually. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..126c502e8 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,408 @@ +# MCP support + +Executor can sit on both sides of MCP: + +- MCP clients connect to Executor's stateful Streamable HTTP endpoint at + `/mcp`. +- Executor imports tools from upstream MCP servers over Streamable HTTP or + local stdio. +- `executor mcp` adapts a local stdio-only MCP client to Executor's `/mcp` + endpoint. + +All three surfaces currently require MCP protocol version `2025-11-25`. +Executor does not negotiate an older protocol version. + +The Rust package pins `rmcp` exactly at `1.8.0` for MCP model types. Executor +implements its HTTP and stdio transport boundaries locally so the gateway can +apply its own authentication, SSRF controls, process isolation, limits, and +lifecycle rules. + +This slice supports MCP tools only. The downstream endpoint does not advertise +resources, prompts, completions, tasks, sampling, or elicitation. Upstream +discovery imports `tools/list`; other upstream capabilities are retained as +metadata but are not bridged into Executor surfaces. + +## Connect an MCP client to Executor + +The downstream endpoint is the instance public origin plus `/mcp`, for example +`http://127.0.0.1:4788/mcp`. Authenticate every non-preflight request with a +dashboard-generated API token: + +```text +Authorization: Bearer +``` + +The endpoint uses the stateful Streamable HTTP lifecycle: + +1. Send `initialize` without `MCP-Session-Id`. +2. Read the new `MCP-Session-Id` response header. +3. Send `notifications/initialized` with that session ID and + `MCP-Protocol-Version: 2025-11-25`. +4. Include both headers on subsequent requests. +5. Send `DELETE /mcp` with both headers to close the session. + +`POST /mcp` requires `Content-Type: application/json` and an `Accept` value +that permits both `application/json` and `text/event-stream`. Responses are +currently JSON request-response messages. `GET /mcp` does not open a server +event stream. After validating an initialized session it returns HTTP 405 with +`Allow: POST, DELETE, OPTIONS`. + +Sessions belong to the API token that initialized them. A different token sees +the session as missing. Sending `DELETE /mcp`, revoking the owning API token, +or lazily pruning an idle-expired session terminates that session and cancels +its active waits. A pending Ask released by session termination cannot execute +later. Initialized sessions expire after eight idle hours; incomplete +handshakes expire after five idle minutes. The server allows 32 sessions per +API token and 4,096 sessions for the instance, returning service unavailable +when either capacity is full. Downstream session IDs are at most 128 printable +ASCII bytes. + +Browser-origin requests must use the configured public origin. Executor checks +both `Host` and `Origin` before authentication and emits narrowly scoped CORS +headers for that origin. Repeated security-sensitive headers are rejected. + +### Virtual tools + +`tools/list` always returns five Executor-owned tools. It does not return every +upstream tool as a separate MCP tool. + +| Tool | Purpose | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `execute` | Run up to 1 MiB of sandboxed TypeScript that can discover and call multiple Executor tools concurrently. Accepts `code` and optional `timeoutMs` from 1 through 300,000. | +| `call` | Call one catalog tool by a 1 through 512 byte `path`, with an optional `arguments` object. Ask-mode tools wait for administrator approval. | +| `search` | Search the enabled global catalog by `query`, optional `namespace`, `limit`, and `offset`. The limit is 1 through 100 and defaults to 20. | +| `describe` | Return schemas and metadata for one enabled catalog tool by a 1 through 512 byte `path`. | +| `sources` | List sources that currently expose tools through the global catalog. | + +Tool paths use `tools..`. Disabled tools remain +unavailable. Imported tools are intrinsically `enabled` only when the upstream +explicitly marks them read-only without also marking them destructive. All +other imported MCP tools default to `ask` mode. + +For `tools/call`, the MCP JSON-RPC request ID is part of the idempotency identity +and cancellation correlation. Numeric and string IDs are distinct; string IDs +are at most 256 bytes and cannot contain a NUL character. Reusing an ID for a +different virtual-tool invocation is rejected. `notifications/cancelled` is +scoped to an in-flight `tools/call` in the same session. A call that may already +have reached an upstream MCP server is never silently replayed. + +### Local stdio bridge + +For clients that can launch only a stdio MCP server, configure them to run: + +```sh +EXECUTOR_API_TOKEN='' \ + executor --base-url http://127.0.0.1:4788 mcp +``` + +The bridge keeps stdout protocol-only, forwards requests concurrently, reserves +capacity for cancellation notifications, and closes the HTTP session on exit. +It allows eight ordinary requests in flight, with a separate ceiling of 16 +while admitting cancellations. Non-loopback plaintext HTTP is rejected unless +the client command also receives `--allow-insecure-http`. + +## Add upstream MCP sources + +MCP source management uses the same administrator routes as other protocols: + +- `POST /api/v1/sources` +- `POST /api/v1/sources/{id}/refresh` +- `GET`, `PUT`, and `DELETE /api/v1/sources/{id}/credentials` +- `GET /api/v1/mcp/stdio/templates` + +Reads require the administrator session cookie. Mutations additionally require +the matching Origin and CSRF cookie/header pair. The dashboard is the normal +way to satisfy these controls. The JSON below documents the protocol payloads +for API clients that already implement administrator authentication. + +### Streamable HTTP source + +Create a remote HTTP source with `kind: "mcp_http"`: + +```json +{ + "kind": "mcp_http", + "displayName": "Issue tracker", + "preferredSlug": "issues", + "description": "Internal issue tools", + "endpoint": "https://mcp.example.com/mcp", + "allowPrivateNetwork": false, + "credential": { + "type": "bearer", + "token": "secret" + } +} +``` + +`preferredSlug`, `description`, and `credential` are optional. +`allowPrivateNetwork` defaults to `false`. The endpoint must be an absolute +HTTP or HTTPS URL without user information or a fragment. A query string is +allowed, but it is encrypted with the credential state and removed from the +public source configuration. + +The HTTP credential is one of: + +```json +{ "type": "bearer", "token": "secret" } +``` + +```json +{ "type": "basic", "username": "user", "password": "secret" } +``` + +```json +{ "type": "api_key_header", "name": "X-Api-Key", "value": "secret" } +``` + +```json +{ "type": "oauth_access_token", "accessToken": "secret" } +``` + +The OAuth form is a manually supplied access token. Managed OAuth discovery, +authorization callbacks, refresh-token rotation, and provider recovery are not +implemented for MCP sources yet. + +Transport-owned headers cannot be used for `api_key_header`. This includes +`Authorization`, `Host`, `Content-Type`, `Accept`, length and connection +headers, `MCP-Session-Id`, `MCP-Protocol-Version`, `Origin`, `Referer`, and +proxy headers. + +Replace the HTTP credential with optimistic concurrency: + +```json +{ + "expectedRevision": 3, + "credential": { + "credential": { + "type": "bearer", + "token": "replacement" + } + } +} +``` + +For a non-null replacement, Executor discovers with the candidate credential +first. One SQLite transaction then replaces the encrypted credential and +refreshes artifacts, bindings, tools, health, and revisions. Discovery or +commit failure leaves the prior credential, catalog, and watcher unchanged. + +Use `"credential": null` inside the protocol credential object to remove HTTP +authentication without changing the endpoint. Executor first attempts +anonymous discovery. On success, one SQLite transaction clears the credential +and refreshes the catalog. After commit, Executor installs a replacement +watcher only when the refreshed capabilities advertise `tools.listChanged`. If +anonymous discovery fails, Executor still commits the credential clear together +with unknown source health, preserves the last known catalog, and leaves its +watcher stopped. A failed clear commit preserves the old database state and +reinstalls its prior watcher. `GET` returns only the revision and configured +credential type, never secret values. + +### Stdio source templates + +Arbitrary commands are never accepted through the source API. The machine +administrator must first approve every executable, argument, working directory, +and non-secret environment value in a strict JSON template file. Start the +server with either form: + +```sh +executor server --mcp-stdio-templates /absolute/path/to/mcp-stdio.json +``` + +```sh +EXECUTOR_MCP_STDIO_TEMPLATES_FILE=/absolute/path/to/mcp-stdio.json executor server +``` + +If neither is set, the registry is empty and HTTP MCP sources remain +available. If a path is set, an unreadable or invalid file fails server +startup. + +The file has exactly this top-level shape: + +```json +{ + "templates": [ + { + "name": "filesystem", + "executable": "/absolute/path/to/mcp-server", + "cwd": "/absolute/path/to/approved-directory", + "arguments": ["--stdio"], + "environment": { + "LOG_LEVEL": "warn" + }, + "secretEnvironment": ["API_TOKEN"] + } + ] +} +``` + +`cwd`, `arguments`, `environment`, and `secretEnvironment` are optional. Unknown +fields fail startup. Template names contain only ASCII letters, digits, `_`, or +`-` and are at most 64 bytes. Template names must be unique. Executables and +working directories must be absolute and are canonicalized when the registry +loads. The executable must be a regular executable file, and `cwd` must be a +directory. + +The configuration file is limited to 1 MiB and 256 templates. Each template +allows at most 128 arguments. The final static-plus-secret environment allows +at most 128 entries. An argument is at most 8 KiB, an environment value is at +most 16 KiB, and the combined argument or environment data for a template is +at most 128 KiB. Environment names use shell-style identifiers. A secret field +cannot duplicate a static environment entry. + +Child processes receive a clean environment containing only the approved +static entries plus the exact secret overlay. On Unix, each child receives its +own process group. Graceful shutdown escalates to killing that process group, +including descendants that remain alive. + +Executor does not enforce ownership or write permissions for the template +file, executable, or their ancestor directories. The machine administrator is +responsible for protecting those paths from untrusted modification. + +After server startup, discover the non-secret template descriptors at +`GET /api/v1/mcp/stdio/templates`: + +```json +{ + "templates": [ + { + "name": "filesystem", + "secretFields": ["API_TOKEN"] + } + ] +} +``` + +Create a source with a template name, never an executable path: + +```json +{ + "kind": "mcp_stdio", + "displayName": "Local filesystem", + "preferredSlug": "files", + "description": "Approved local file tools", + "templateName": "filesystem", + "secretValues": { + "API_TOKEN": "secret" + } +} +``` + +`preferredSlug`, `description`, and `secretValues` are optional. If a template +declares secrets and `secretValues` is omitted or empty, Executor creates an +empty source without starting the process. A later credential update must +supply every declared secret and no others, then discovery runs. Credential +replacement uses: + +```json +{ + "expectedRevision": 0, + "credential": { + "secretValues": { + "API_TOKEN": "replacement" + } + } +} +``` + +Credential replacement discovers first, then commits the encrypted credential +and refreshed catalog atomically. A discovery failure leaves both unchanged. +Clear credentials with +`DELETE /api/v1/sources/{id}/credentials?expectedRevision=`. For HTTP +sources, the nested `"credential": null` PUT form described above has the same +clear semantics. + +Clearing credentials for a template that declares secrets stops its catalog +watcher and leaves both the approved template selection and existing catalog +entries in place. The empty encrypted overlay and unknown source health commit +together; calls cannot start until complete credentials are restored. +Templates without secret fields treat the empty overlay as complete, discover +first, and atomically commit the empty credential plus refreshed catalog so the +source remains healthy. + +## Discovery, refresh, and invocation lifecycle + +When credentials are complete, source creation initializes the upstream, walks +every `tools/list` page, validates and normalizes the complete result, then +commits the source, encrypted credential envelope, capabilities artifact, +bindings, and tools atomically. A stdio source created without its required +secrets follows the deferred empty-source behavior described above. Discovery +permits at most 1,000 pages, 100,000 tools, 32 MiB of aggregate tool metadata, +5 MiB for one tool, 2 MiB for one schema, and 8 MiB of capability metadata. +Cursor cycles, duplicate tool names, malformed schemas, and unstable catalogs +fail the operation. + +Refresh performs all upstream work before opening the catalog transaction. It +commits only if the source and credential revisions still match. A failed or +stale refresh leaves the existing catalog untouched. When the retained +credential state remains complete, that catalog remains callable. Upstream tool +names are stable keys, so refresh preserves Executor tool IDs, callable paths, +and administrator mode overrides. + +If an upstream advertises `tools.listChanged`, Executor maintains a watcher and +coalesces notifications into bounded refreshes. Watchers are restored at +startup and run a full revision-fenced catalog reconciliation on every +successful initial or reconnect handshake before entering steady notification +handling. They are replaced after relevant credential changes, stopped before +source deletion, and drained during shutdown. Stdio watchers also heartbeat the +transport lifecycle so an idle child exit reconnects even without a +notification. If an HTTP upstream returns 405 to the notification-stream GET, +Executor lets the current revision-fenced reconciliation commit, then treats +live notifications as unsupported and exits that watcher without reconnecting. +Use manual refresh for later catalog changes from that upstream. + +An invocation uses a fresh upstream connection and MCP session. Executor +initializes it, calls the named tool, then terminates or shuts down the session. +An upstream `isError: true` result becomes a failed Executor tool result with +code `upstream_tool_error`. Disconnects after a stdio tool call or transport +failures during an HTTP tool call are treated as outcome-unknown and are not +replayed. + +## Transport safety and limits + +Remote HTTP MCP uses the shared hardened outbound client. It allows only HTTP +and HTTPS, disables environment proxies and redirects, validates and pins DNS +answers, and blocks private targets unless the source explicitly opts in. +Link-local, cloud metadata, multicast, documentation, and reserved addresses +remain blocked even with private-network access enabled. + +An MCP endpoint is capped at 8 KiB. The shared client limits a resolved host to +32 addresses, request bodies to 8 MiB, collected request-response bodies to 16 +MiB, and request or response headers to 64 KiB. Connection setup has a +five-second timeout. The HTTP MCP transport adds a 30-second deadline to +request-response exchanges, a 16 MiB aggregate ceiling across request-response +SSE resumptions, at most 1,024 SSE events per exchange, at most three +resumptions, and session IDs capped at 1,024 visible ASCII bytes. + +Long-lived notification streams have no cumulative byte or lifetime limit. +They use a five-minute per-read idle timeout; declared `Content-Length`, each +delivered body chunk, each undelimited parser line, and each accumulated SSE +event are independently capped at 16 MiB. A connection accepts at most 1,024 +completed SSE events. Event 1,025 fails the listener, after which the source +watcher reconnects with backoff and a fresh MCP initialization. The transport +accepts only JSON or SSE response media types. Session changes, malformed +JSON-RPC, unsupported server requests, and invalid content types fail closed. +Server `ping` requests are answered; other server-initiated requests receive +method not found. + +The stdio transport defaults to a 16 MiB message limit, 64 KiB stderr +accounting ceiling, 60-second request timeout, two-second shutdown grace, and a +256-command queue. Stderr is continuously drained and is not exposed or stored; +only the exit code and whether the accounting ceiling was exceeded survive +internally. The transport restarts a failed process with bounded exponential +backoff, but a restart requires a new MCP initialization. It does not retry a +tool call after bytes may have reached the child. + +The downstream `/mcp` body limit is the 8 MiB tool-argument ceiling plus a +64 KiB protocol envelope. Bearer tokens are capped at 512 bytes. Body admission +is limited to 16 requests for the instance and four per token. Detached +execution is independently limited to 16 for the instance and four per token, +leaving a control lane available for cancellation notifications. Body +saturation returns HTTP 429 with `mcp_busy`; execution saturation returns a +JSON-RPC `-32603` error. The cancellation registry is capped at 4,096 active +requests, with at most 128 short-lived pre-cancellation records per session. +Pre-cancellation records expire after 60 seconds. Pending Ask calls use the +durable ten-minute approval TTL, and expiry settlement is checked at intervals +of at most 30 seconds. There is no separate MCP transport wait timeout. +`notifications/cancelled` explicitly cancels the matching request wait; +`DELETE /mcp`, API-token revocation, and lazy idle-session expiry cancel all +session-scoped waits. diff --git a/src/api.rs b/src/api.rs index fc44ea26e..8f67f7870 100644 --- a/src/api.rs +++ b/src/api.rs @@ -29,6 +29,7 @@ use crate::{ database::{Database, SETUP_TOKEN_TTL_SECONDS}, execution::ExecutionService, invocation::ToolCallService, + mcp::downstream::McpState, protocols::SourceService, request_logs::RequestLogSink, tasks::TaskTracker, @@ -69,6 +70,7 @@ struct AppState { sources: SourceService, tool_calls: ToolCallService, execution: ExecutionService, + mcp: McpState, request_logs: RequestLogSink, origin: Arc, session_ttl_seconds: i64, @@ -425,6 +427,7 @@ pub(crate) struct ApiServices { sources: SourceService, tool_calls: ToolCallService, execution: ExecutionService, + mcp: McpState, } impl ApiServices { @@ -432,11 +435,13 @@ impl ApiServices { sources: SourceService, tool_calls: ToolCallService, execution: ExecutionService, + mcp: McpState, ) -> Self { Self { sources, tool_calls, execution, + mcp, } } } @@ -515,6 +520,7 @@ pub(crate) fn router( sources: services.sources, tool_calls: services.tool_calls, execution: services.execution, + mcp: services.mcp, request_logs, origin: Arc::from(config.public_origin()), session_ttl_seconds: config.session_ttl_seconds, @@ -541,6 +547,7 @@ pub(crate) fn router( .merge(approvals::router()) .merge(protocols::router()) .merge(openapi::router()) + .merge(crate::mcp::downstream::router(state.mcp.clone())) .fallback(not_found) .method_not_allowed_fallback(method_not_allowed) .with_state(state) @@ -1028,6 +1035,7 @@ async fn revoke_token( )); } state.execution.revoke_owner(&token_id).await; + state.mcp.revoke_token(&token_id); Ok(StatusCode::NO_CONTENT) } diff --git a/src/api/catalog.rs b/src/api/catalog.rs index 027e54849..fad6b3b9e 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -93,10 +93,10 @@ async fn delete_source( let admin = require_admin_mutation(&request_id, &state, &headers).await?; let Path(source_id) = parse_path(&request_id, path)?; state - .catalog - .delete_source(&source_id, AuditContext::admin(&request_id.0, admin.id)) + .sources + .delete(&source_id, AuditContext::admin(&request_id.0, admin.id)) .await - .map_err(|error| catalog_error(&request_id, error))?; + .map_err(|error| super::protocols::protocol_error(&request_id, error))?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/api/protocols.rs b/src/api/protocols.rs index a1542905f..21cb80df9 100644 --- a/src/api/protocols.rs +++ b/src/api/protocols.rs @@ -20,7 +20,7 @@ use crate::{ execution::{ExecuteCodeRequest, ExecutionServiceError}, invocation::{ GatewayInvokeError, GatewayInvokeResponse, ToolCall, ToolCallError, ToolCallSubmission, - gateway_idempotency_key_is_valid, + ToolDiscoveryError, gateway_idempotency_key_is_valid, }, outbound::OutboundError, protocols::{CredentialMetadata, ProtocolError, ProtocolErrorCategory}, @@ -41,6 +41,7 @@ pub(super) fn router() -> Router { .merge( Router::new() .route("/api/v1/sources/{id}/refresh", post(refresh_source)) + .route("/api/v1/mcp/stdio/templates", get(stdio_templates)) .route( "/api/v1/sources/{id}/credentials", get(get_credentials) @@ -51,6 +52,7 @@ pub(super) fn router() -> Router { .merge( Router::new() .route("/api/v1/gateway/tools/invoke", post(invoke)) + .route("/api/v1/gateway/sources", get(gateway_sources)) .layer(DefaultBodyLimit::max(MAX_INVOKE_BODY_BYTES)), ) .merge( @@ -60,6 +62,115 @@ pub(super) fn router() -> Router { ) } +#[derive(Serialize)] +struct StdioTemplatesResponse { + templates: Vec, +} + +async fn stdio_templates( + _admin: AdminAuthentication, + State(state): State, +) -> Json { + Json(StdioTemplatesResponse { + templates: state.sources.stdio_template_descriptors(), + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GatewaySourcesResponse { + sources: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GatewaySourceResponse { + slug: String, + display_name: String, + description: Option, + kind: SourceKind, + tool_count: usize, +} + +async fn gateway_sources( + Extension(request_id): Extension, + State(state): State, + GatewayAuthentication(identity): GatewayAuthentication, +) -> Result, ApiError> { + let call = ToolCall { + request_id: request_id.0.clone(), + actor: ToolActor::api_token(identity.token_id, Some(identity.token_name)), + surface: RequestSurface::Gateway, + execution_id: request_id.0.clone(), + call_id: "gateway.sources".to_owned(), + worker_generation: 0, + path: "executor.sources".to_owned(), + arguments: Value::Object(Map::new()), + }; + let discovered = state + .tool_calls + .discover_sources(&call) + .await + .map_err(|error| discovery_error(&request_id, error))?; + let Value::Array(items) = discovered else { + return Err(ApiError::internal_logged( + &request_id, + "source discovery returned an invalid payload", + )); + }; + let mut sources = Vec::with_capacity(items.len()); + for item in items { + let Some(item) = item.as_object() else { + return Err(ApiError::internal(&request_id)); + }; + let source = GatewaySourceResponse { + slug: required_string(item, "slug").ok_or_else(|| ApiError::internal(&request_id))?, + display_name: required_string(item, "displayName") + .ok_or_else(|| ApiError::internal(&request_id))?, + description: item + .get("description") + .and_then(Value::as_str) + .map(str::to_owned), + kind: serde_json::from_value( + item.get("kind") + .cloned() + .ok_or_else(|| ApiError::internal(&request_id))?, + ) + .map_err(|_| ApiError::internal(&request_id))?, + tool_count: item + .get("toolCount") + .and_then(Value::as_u64) + .and_then(|count| usize::try_from(count).ok()) + .ok_or_else(|| ApiError::internal(&request_id))?, + }; + sources.push(source); + } + Ok(Json(GatewaySourcesResponse { sources })) +} + +fn required_string(item: &Map, field: &str) -> Option { + item.get(field).and_then(Value::as_str).map(str::to_owned) +} + +fn discovery_error(request_id: &RequestId, error: ToolDiscoveryError) -> ApiError { + match error { + ToolDiscoveryError::Busy => ApiError::new( + request_id, + StatusCode::TOO_MANY_REQUESTS, + "search_busy", + "Tool discovery is busy. Try again shortly.", + ) + .with_retry_after(1), + ToolDiscoveryError::Catalog(error) => catalog_error(request_id, error), + ToolDiscoveryError::Interrupted => ApiError::new( + request_id, + StatusCode::SERVICE_UNAVAILABLE, + "discovery_interrupted", + "Tool discovery was interrupted. Try again.", + ), + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ExecuteRequest { @@ -495,6 +606,13 @@ fn gateway_invoke_error(request_id: &RequestId, error: GatewayInvokeError) -> Ap "idempotency_outcome_unknown", "The invocation may have reached the upstream service, so it will not be retried.", ), + GatewayInvokeError::Canceled => ApiError::new( + request_id, + StatusCode::CONFLICT, + "idempotency_in_progress", + "The invocation is still in progress.", + ) + .with_retry_after(1), GatewayInvokeError::Idempotency(error) => ApiError::internal_logged(request_id, error), } } diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index fca9dd4a0..291e7116e 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -271,24 +271,44 @@ pub struct StagedToolBinding { #[serde(tag = "kind", content = "definition", rename_all = "snake_case")] pub enum ToolBinding { OpenapiV1(OpenApiBinding), + McpHttpV1(McpToolBindingV1), + McpStdioV1(McpToolBindingV1), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct McpToolBindingV1 { + pub version: u32, + pub tool_name: String, } impl ToolBinding { pub const fn protocol(&self) -> &'static str { match self { Self::OpenapiV1(_) => "openapi", + Self::McpHttpV1(_) => "mcp_http", + Self::McpStdioV1(_) => "mcp_stdio", } } pub const fn version(&self) -> i64 { match self { Self::OpenapiV1(_) => 1, + Self::McpHttpV1(_) | Self::McpStdioV1(_) => 1, } } pub fn openapi(&self) -> Option<&OpenApiBinding> { match self { Self::OpenapiV1(binding) => Some(binding), + Self::McpHttpV1(_) | Self::McpStdioV1(_) => None, + } + } + + pub fn mcp(&self) -> Option<&McpToolBindingV1> { + match self { + Self::McpHttpV1(binding) | Self::McpStdioV1(binding) => Some(binding), + Self::OpenapiV1(_) => None, } } @@ -307,6 +327,19 @@ impl ToolBinding { } Ok(Self::OpenapiV1(binding)) } + ("mcp_http", 1) | ("mcp_stdio", 1) => { + let binding: McpToolBindingV1 = serde_json::from_str(definition_json)?; + if !valid_mcp_binding(&binding) { + return Err(CatalogError::CorruptData( + "unsupported MCP binding version or tool name", + )); + } + Ok(if protocol == "mcp_http" { + Self::McpHttpV1(binding) + } else { + Self::McpStdioV1(binding) + }) + } _ => Err(CatalogError::CorruptData( "unknown tool binding protocol or version", )), @@ -314,6 +347,14 @@ impl ToolBinding { } } +fn valid_mcp_binding(binding: &McpToolBindingV1) -> bool { + binding.version == 1 + && !binding.tool_name.is_empty() + && binding.tool_name.chars().count() <= 128 + && binding.tool_name.trim() == binding.tool_name + && !binding.tool_name.chars().any(char::is_control) +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoredToolBinding { pub tool_id: String, diff --git a/src/catalog/store.rs b/src/catalog/store.rs index 4b352a5dc..041e37ffb 100644 --- a/src/catalog/store.rs +++ b/src/catalog/store.rs @@ -52,6 +52,7 @@ const MAX_SEARCH_DESCRIPTION_BYTES: usize = 32 * 1024; const MAX_SHORT_GRAM_DOCUMENT_BYTES: usize = 16 * 1024; const MAX_LOCAL_NAME_BYTES: usize = 128; const MAX_CATALOG_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; +const MAX_SOURCE_HEALTH_ERROR_CODE_BYTES: usize = 64; #[derive(Clone)] pub struct CatalogStore { @@ -298,10 +299,39 @@ impl CatalogStore { bindings: Vec, audit: AuditContext<'_>, ) -> Result<(SourceRecord, CatalogSyncResult), CatalogError> { - if input.kind != SourceKind::Openapi { + self.create_source_with_catalog_health( + input, + credential, + snapshot, + bindings, + SourceHealth::Healthy, + audit, + ) + .await + } + + pub async fn create_source_with_catalog_health( + &self, + input: CreateSource, + credential: &CredentialPayload, + snapshot: InitialCatalogSnapshot, + bindings: Vec, + initial_health: SourceHealth, + audit: AuditContext<'_>, + ) -> Result<(SourceRecord, CatalogSyncResult), CatalogError> { + if !matches!( + input.kind, + SourceKind::Openapi | SourceKind::McpHttp | SourceKind::McpStdio + ) { return Err(validation( "invalid_source_kind", - "Atomic imported-source creation currently supports OpenAPI sources only.", + "Atomic imported-source creation supports OpenAPI and MCP sources.", + )); + } + if initial_health == SourceHealth::Error { + return Err(validation( + "invalid_initial_source_health", + "Atomic source creation supports healthy or unknown initial health.", )); } let display_name = validate_text( @@ -339,6 +369,15 @@ impl CatalogStore { "Every staged imported tool must have exactly one binding.", )); } + if prepared_bindings + .iter() + .any(|binding| binding.protocol != input.kind.as_str()) + { + return Err(validation( + "invalid_source_kind", + "Every staged tool binding must match its source protocol.", + )); + } let source_id = Uuid::new_v4().to_string(); let configuration_json = serde_json::to_string(&input.configuration)?; @@ -359,11 +398,17 @@ impl CatalogStore { used_slugs.extend(RESERVED_SOURCE_SLUGS.into_iter().map(str::to_owned)); let slug = NameAllocator::new(used_slugs).allocate(&base_slug, 63, '_'); let search_short_grams = search::short_gram_document(&[&slug]); + let initial_health = match initial_health { + SourceHealth::Unknown => "unknown", + SourceHealth::Healthy => "healthy", + SourceHealth::Error => unreachable!("error initial health was rejected"), + }; + let last_refreshed_at = (initial_health == "healthy").then_some(now); sqlx::query( "INSERT INTO sources \ (id, kind, slug, search_short_grams, display_name, description, configuration_json, \ health_status, revision, catalog_revision, created_at, updated_at, last_refreshed_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, 'healthy', 1, 1, ?, ?, ?)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?)", ) .bind(&source_id) .bind(input.kind.as_str()) @@ -372,9 +417,10 @@ impl CatalogStore { .bind(display_name) .bind(description) .bind(configuration_json) + .bind(initial_health) .bind(now) .bind(now) - .bind(now) + .bind(last_refreshed_at) .execute(&mut *transaction) .await?; sqlx::query( @@ -677,6 +723,127 @@ impl CatalogStore { self.source(source_id).await } + pub async fn mark_source_credential_required( + &self, + source_id: &str, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + self.set_source_health( + source_id, + SourceHealth::Unknown, + None, + "credential_required", + expected_revision, + audit, + ) + .await + } + + pub async fn mark_source_error( + &self, + source_id: &str, + error_code: &'static str, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + self.set_source_health( + source_id, + SourceHealth::Error, + Some(error_code), + error_code, + expected_revision, + audit, + ) + .await + } + + async fn set_source_health( + &self, + source_id: &str, + health: SourceHealth, + health_error_code: Option<&'static str>, + transition_code: &'static str, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + validate_source_health_error_code(transition_code)?; + if let Some(error_code) = health_error_code { + validate_source_health_error_code(error_code)?; + } + let _write = self.mutation_lock.write().await; + let now = unix_timestamp(); + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let current = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(source_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })?; + let health_status = match health { + SourceHealth::Unknown => "unknown", + SourceHealth::Healthy => "healthy", + SourceHealth::Error => "error", + }; + if current.health_status == health_status + && current.health_error_code.as_deref() == health_error_code + { + let source = current.try_into()?; + transaction.commit().await?; + return Ok(source); + } + if current.revision != expected_revision { + return Err(CatalogError::RevisionConflict { + scope: "source", + expected: expected_revision, + actual: current.revision, + }); + } + let changed = sqlx::query( + "UPDATE sources SET health_status = ?, health_error_code = ?, \ + revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?", + ) + .bind(health_status) + .bind(health_error_code) + .bind(now) + .bind(source_id) + .bind(expected_revision) + .execute(&mut *transaction) + .await? + .rows_affected(); + if changed != 1 { + return Err(CatalogError::RevisionConflict { + scope: "source", + expected: expected_revision, + actual: current.revision, + }); + } + let global_revision = bump_global_revision(&mut transaction, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.health_changed", + Some(source_id), + None, + Some(&source_path), + json!({ + "healthStatus": health, + "errorCode": health_error_code, + "reasonCode": transition_code, + "globalRevision": global_revision, + }), + now, + ) + .await?; + let source = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(source_id) + .fetch_one(&mut *transaction) + .await? + .try_into()?; + transaction.commit().await?; + Ok(source) + } + pub async fn put_credential( &self, source_id: &str, @@ -843,6 +1010,143 @@ impl CatalogStore { Ok(()) } + pub async fn replace_credential_and_mark_unknown( + &self, + source_id: &str, + credential: &CredentialPayload, + expected_source_revision: i64, + expected_credential_revision: i64, + audit: AuditContext<'_>, + ) -> Result<(StoredCredential, SourceRecord), CatalogError> { + let ciphertext = self.prepare_credential_ciphertext(source_id, credential)?; + let now = unix_timestamp(); + let _write = self.mutation_lock.write().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + ensure_source_revision(&mut transaction, source_id, expected_source_revision).await?; + let credential_revision = replace_credential_in_transaction( + &mut transaction, + source_id, + credential.schema_version, + ciphertext, + expected_credential_revision, + now, + ) + .await?; + let source_revision = sqlx::query_scalar::<_, i64>( + "UPDATE sources SET health_status = 'unknown', health_error_code = NULL, \ + revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ? \ + RETURNING revision", + ) + .bind(now) + .bind(source_id) + .bind(expected_source_revision) + .fetch_one(&mut *transaction) + .await?; + let global_revision = bump_global_revision(&mut transaction, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.credential_changed", + Some(source_id), + None, + Some(&source_path), + json!({ "schemaVersion": credential.schema_version }), + now, + ) + .await?; + insert_audit( + &mut transaction, + audit, + "source.health_changed", + Some(source_id), + None, + Some(&source_path), + json!({ + "healthStatus": SourceHealth::Unknown, + "errorCode": null, + "reasonCode": "credential_required", + "globalRevision": global_revision, + }), + now, + ) + .await?; + let source: SourceRecord = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(source_id) + .fetch_one(&mut *transaction) + .await? + .try_into()?; + transaction.commit().await?; + debug_assert_eq!(source_revision, source.revision); + Ok(( + StoredCredential { + revision: credential_revision, + credential: credential.clone(), + }, + source, + )) + } + + pub async fn delete_credential_and_mark_unknown( + &self, + source_id: &str, + expected_source_revision: i64, + expected_credential_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + let now = unix_timestamp(); + let _write = self.mutation_lock.write().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + ensure_source_revision(&mut transaction, source_id, expected_source_revision).await?; + delete_credential_in_transaction(&mut transaction, source_id, expected_credential_revision) + .await?; + sqlx::query( + "UPDATE sources SET health_status = 'unknown', health_error_code = NULL, \ + revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?", + ) + .bind(now) + .bind(source_id) + .bind(expected_source_revision) + .execute(&mut *transaction) + .await?; + let global_revision = bump_global_revision(&mut transaction, now).await?; + let source_path = source_path_snapshot(&mut transaction, source_id).await?; + insert_audit( + &mut transaction, + audit, + "source.credential_deleted", + Some(source_id), + None, + Some(&source_path), + json!({}), + now, + ) + .await?; + insert_audit( + &mut transaction, + audit, + "source.health_changed", + Some(source_id), + None, + Some(&source_path), + json!({ + "healthStatus": SourceHealth::Unknown, + "errorCode": null, + "reasonCode": "credential_required", + "globalRevision": global_revision, + }), + now, + ) + .await?; + let source: SourceRecord = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(source_id) + .fetch_one(&mut *transaction) + .await? + .try_into()?; + transaction.commit().await?; + Ok(source) + } + #[cfg(test)] pub(crate) async fn replace_tool_bindings( &self, @@ -855,10 +1159,22 @@ impl CatalogStore { let now = unix_timestamp(); let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let source_kind = source_kind(&mut transaction, source_id).await?; - if source_kind != SourceKind::Openapi { + if !matches!( + source_kind, + SourceKind::Openapi | SourceKind::McpHttp | SourceKind::McpStdio + ) { return Err(validation( "invalid_source_kind", - "OpenAPI tool bindings may only be stored for OpenAPI sources.", + "Imported tool bindings may only be stored for imported sources.", + )); + } + if prepared + .iter() + .any(|binding| binding.protocol != source_kind.as_str()) + { + return Err(validation( + "invalid_source_kind", + "Every tool binding must match its source protocol.", )); } let active_count = sqlx::query_scalar::<_, i64>( @@ -927,10 +1243,7 @@ impl CatalogStore { entity: "tool binding", })?; let binding = ToolBinding::decode(&row.3, row.4, &row.5)?; - if !matches!( - (&binding, SourceKind::from_str(&row.2)?), - (ToolBinding::OpenapiV1(_), SourceKind::Openapi) - ) { + if !binding_matches_source_kind(&binding, SourceKind::from_str(&row.2)?) { return Err(CatalogError::CorruptData( "tool binding does not match source kind", )); @@ -966,85 +1279,119 @@ impl CatalogStore { let now = unix_timestamp(); let _write = self.mutation_lock.write().await; let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; - let (actual_source_revision, source_kind) = - sqlx::query_as::<_, (i64, String)>("SELECT revision, kind FROM sources WHERE id = ?") - .bind(source_id) - .fetch_optional(&mut *transaction) - .await? - .ok_or(CatalogError::NotFound { entity: "source" })?; - if actual_source_revision != expected_source_revision { - return Err(CatalogError::RevisionConflict { - scope: "source", - expected: expected_source_revision, - actual: actual_source_revision, - }); - } - let source_kind = SourceKind::from_str(&source_kind)?; - let binding_keys = prepared_bindings - .iter() - .map(|binding| &binding.stable_key) - .collect::>(); - let staged_tool_keys = prepared - .tools - .iter() - .map(|tool| &tool.stable_key) - .collect::>(); - match source_kind { - SourceKind::Openapi if binding_keys != staged_tool_keys => { - return Err(validation( - "incomplete_tool_bindings", - "Every active OpenAPI tool must have exactly one binding.", - )); - } - SourceKind::Openapi => {} - _ if !prepared_bindings.is_empty() => { - return Err(validation( - "invalid_source_kind", - "OpenAPI tool bindings may only be stored for OpenAPI sources.", - )); - } - _ => {} - } - let actual_credential_revision = sqlx::query_scalar::<_, i64>( - "SELECT revision FROM source_credentials WHERE source_id = ?", + validate_catalog_apply_basis( + &mut transaction, + source_id, + expected_source_revision, + expected_credential_revision, + &prepared, + &prepared_bindings, ) - .bind(source_id) - .fetch_optional(&mut *transaction) .await?; - if actual_credential_revision != expected_credential_revision { - return Err(CatalogError::RevisionConflict { - scope: "credential", - expected: expected_credential_revision.unwrap_or(-1), - actual: actual_credential_revision.unwrap_or(-1), - }); - } - apply_artifacts(&mut transaction, source_id, &prepared.artifacts, now).await?; - let missing = apply_tools(&mut transaction, source_id, &prepared.tools, now).await?; - apply_tool_bindings(&mut transaction, source_id, &prepared_bindings, now).await?; - rebuild_search_indexes(&mut transaction, source_id).await?; + let result = apply_prepared_catalog_refresh( + &mut transaction, + source_id, + &prepared, + &prepared_bindings, + audit, + now, + ) + .await?; + transaction.commit().await?; + Ok(result) + } + pub async fn replace_credential_and_sync_catalog( + &self, + source_id: &str, + credential: &CredentialPayload, + snapshot: CatalogSnapshot, + bindings: Vec, + audit: AuditContext<'_>, + ) -> Result<(StoredCredential, CatalogSyncResult, SourceRecord), CatalogError> { + let expected_source_revision = snapshot.expected_source_revision; + let expected_credential_revision = + snapshot.expected_credential_revision.ok_or_else(|| { + validation( + "credential_required", + "Atomic credential replacement requires an existing credential revision.", + ) + })?; + let (prepared, prepared_bindings) = prepare_snapshot_and_bindings(snapshot, bindings)?; + let ciphertext = self.prepare_credential_ciphertext(source_id, credential)?; + let now = unix_timestamp(); + let _write = self.mutation_lock.write().await; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + validate_catalog_apply_basis( + &mut transaction, + source_id, + expected_source_revision, + Some(expected_credential_revision), + &prepared, + &prepared_bindings, + ) + .await?; + let credential_revision = replace_credential_in_transaction( + &mut transaction, + source_id, + credential.schema_version, + ciphertext, + expected_credential_revision, + now, + ) + .await?; let source_path = source_path_snapshot(&mut transaction, source_id).await?; - let (source_revision, catalog_revision, global_revision) = finalize_catalog_apply( + insert_audit( &mut transaction, audit, + "source.credential_changed", + Some(source_id), + None, + Some(&source_path), + json!({ "schemaVersion": credential.schema_version }), + now, + ) + .await?; + let sync = apply_prepared_catalog_refresh( + &mut transaction, source_id, - &source_path, - CatalogApplyKind::Refresh, - prepared.tools.len(), - prepared.artifacts.len(), - missing.len(), + &prepared, + &prepared_bindings, + audit, now, ) .await?; + let source: SourceRecord = sqlx::query_as::<_, SourceRow>(SOURCE_SELECT_BY_ID) + .bind(source_id) + .fetch_one(&mut *transaction) + .await? + .try_into()?; transaction.commit().await?; - Ok(CatalogSyncResult { - source_id: source_id.to_owned(), - source_revision, - catalog_revision, - global_revision, - active_tool_count: prepared.tools.len(), - tombstoned_tool_count: missing.len(), - }) + Ok(( + StoredCredential { + revision: credential_revision, + credential: credential.clone(), + }, + sync, + source, + )) + } + + fn prepare_credential_ciphertext( + &self, + source_id: &str, + credential: &CredentialPayload, + ) -> Result, CatalogError> { + if credential.schema_version == 0 { + return Err(validation( + "invalid_credential_schema", + "Credential schema versions must be positive.", + )); + } + let plaintext = serde_json::to_vec(&credential.payload)?; + Ok(self + .keyring + .encrypt(CREDENTIAL_PURPOSE, source_id, &plaintext)?) } pub async fn list_tools(&self, filter: ListToolsFilter) -> Result { @@ -1543,10 +1890,7 @@ impl CatalogStore { .ok_or(CatalogError::CorruptData("active tool binding missing"))?; let binding = ToolBinding::decode(binding_protocol, binding_version, definition_json)?; let source_kind = SourceKind::from_str(&row.source_kind)?; - if !matches!( - (&binding, source_kind), - (ToolBinding::OpenapiV1(_), SourceKind::Openapi) - ) { + if !binding_matches_source_kind(&binding, source_kind) { return Err(CatalogError::CorruptData( "tool binding does not match source kind", )); @@ -1643,10 +1987,7 @@ impl CatalogStore { .binding_revision .ok_or(CatalogError::CorruptData("active tool binding missing"))?; let binding = ToolBinding::decode(binding_protocol, binding_version, definition_json)?; - if !matches!( - (&binding, SourceKind::from_str(&row.source_kind)?), - (ToolBinding::OpenapiV1(_), SourceKind::Openapi) - ) { + if !binding_matches_source_kind(&binding, SourceKind::from_str(&row.source_kind)?) { return Err(CatalogError::CorruptData( "tool binding does not match source kind", )); @@ -2225,6 +2566,26 @@ fn prepare_tool_bindings_with_limits( } serde_json::to_string(binding)? } + ToolBinding::McpHttpV1(binding) | ToolBinding::McpStdioV1(binding) => { + if binding.version != 1 + || binding.tool_name.is_empty() + || binding.tool_name.chars().count() > 128 + || binding.tool_name.trim() != binding.tool_name + || binding.tool_name.chars().any(char::is_control) + { + return Err(validation( + "invalid_tool_binding", + "The MCP tool binding version or tool name is not supported.", + )); + } + if binding.tool_name != stable_key { + return Err(validation( + "invalid_tool_binding", + "An MCP tool binding name must match its stable catalog key.", + )); + } + serde_json::to_string(binding)? + } }; if definition_json.len() > limits.schema { return Err(validation( @@ -2483,6 +2844,15 @@ fn parse_optional_mode(value: Option) -> Result, Catalo value.map(|value| ToolMode::from_str(&value)).transpose() } +fn binding_matches_source_kind(binding: &ToolBinding, source_kind: SourceKind) -> bool { + matches!( + (binding, source_kind), + (ToolBinding::OpenapiV1(_), SourceKind::Openapi) + | (ToolBinding::McpHttpV1(_), SourceKind::McpHttp) + | (ToolBinding::McpStdioV1(_), SourceKind::McpStdio) + ) +} + fn normalize_source_slug(value: &str) -> String { normalize_identifier(value, "source") } @@ -2699,6 +3069,32 @@ fn validate_optional_text( .transpose() } +fn validate_source_health_error_code(error_code: &str) -> Result<(), CatalogError> { + let characters = error_code.as_bytes(); + let valid = error_code.len() <= MAX_SOURCE_HEALTH_ERROR_CODE_BYTES + && characters + .first() + .is_some_and(|character| character.is_ascii_lowercase()) + && characters + .last() + .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) + && characters.iter().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || *character == b'_' + }) + && characters.windows(2).all(|pair| pair != b"__"); + if valid { + Ok(()) + } else { + Err(validation( + "invalid_source_health_error_code", + format!( + "Source health error codes must contain at most \ + {MAX_SOURCE_HEALTH_ERROR_CODE_BYTES} ASCII bytes and use lower_snake_case." + ), + )) + } +} + fn validation(code: &'static str, message: impl Into) -> CatalogError { CatalogError::Validation { code, @@ -2762,6 +3158,114 @@ fn normalize_sandbox_path(path: &str) -> String { path.strip_prefix("tools.").unwrap_or(path).to_owned() } +async fn validate_catalog_apply_basis( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + expected_source_revision: i64, + expected_credential_revision: Option, + prepared: &PreparedSnapshot, + prepared_bindings: &[PreparedToolBinding], +) -> Result { + let (actual_source_revision, source_kind) = + sqlx::query_as::<_, (i64, String)>("SELECT revision, kind FROM sources WHERE id = ?") + .bind(source_id) + .fetch_optional(&mut **transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })?; + if actual_source_revision != expected_source_revision { + return Err(CatalogError::RevisionConflict { + scope: "source", + expected: expected_source_revision, + actual: actual_source_revision, + }); + } + let source_kind = SourceKind::from_str(&source_kind)?; + let binding_keys = prepared_bindings + .iter() + .map(|binding| &binding.stable_key) + .collect::>(); + let staged_tool_keys = prepared + .tools + .iter() + .map(|tool| &tool.stable_key) + .collect::>(); + match source_kind { + SourceKind::Openapi | SourceKind::McpHttp | SourceKind::McpStdio + if binding_keys != staged_tool_keys => + { + return Err(validation( + "incomplete_tool_bindings", + "Every active imported tool must have exactly one binding.", + )); + } + SourceKind::Openapi | SourceKind::McpHttp | SourceKind::McpStdio => {} + _ if !prepared_bindings.is_empty() => { + return Err(validation( + "invalid_source_kind", + "Imported tool bindings may only be stored for imported sources.", + )); + } + _ => {} + } + if prepared_bindings + .iter() + .any(|binding| binding.protocol != source_kind.as_str()) + { + return Err(validation( + "invalid_source_kind", + "Every staged tool binding must match its source protocol.", + )); + } + let actual_credential_revision = + sqlx::query_scalar::<_, i64>("SELECT revision FROM source_credentials WHERE source_id = ?") + .bind(source_id) + .fetch_optional(&mut **transaction) + .await?; + if actual_credential_revision != expected_credential_revision { + return Err(CatalogError::RevisionConflict { + scope: "credential", + expected: expected_credential_revision.unwrap_or(-1), + actual: actual_credential_revision.unwrap_or(-1), + }); + } + Ok(source_kind) +} + +async fn apply_prepared_catalog_refresh( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + prepared: &PreparedSnapshot, + prepared_bindings: &[PreparedToolBinding], + audit: AuditContext<'_>, + now: i64, +) -> Result { + apply_artifacts(transaction, source_id, &prepared.artifacts, now).await?; + let missing = apply_tools(transaction, source_id, &prepared.tools, now).await?; + apply_tool_bindings(transaction, source_id, prepared_bindings, now).await?; + rebuild_search_indexes(transaction, source_id).await?; + let source_path = source_path_snapshot(transaction, source_id).await?; + let (source_revision, catalog_revision, global_revision) = finalize_catalog_apply( + transaction, + audit, + source_id, + &source_path, + CatalogApplyKind::Refresh, + prepared.tools.len(), + prepared.artifacts.len(), + missing.len(), + now, + ) + .await?; + Ok(CatalogSyncResult { + source_id: source_id.to_owned(), + source_revision, + catalog_revision, + global_revision, + active_tool_count: prepared.tools.len(), + tombstoned_tool_count: missing.len(), + }) +} + #[allow(clippy::too_many_arguments)] async fn finalize_catalog_apply( transaction: &mut Transaction<'_, sqlx::Sqlite>, @@ -3096,6 +3600,91 @@ async fn ensure_source_exists( } } +async fn ensure_source_revision( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + expected_revision: i64, +) -> Result<(), CatalogError> { + let actual_revision = sqlx::query_scalar::<_, i64>("SELECT revision FROM sources WHERE id = ?") + .bind(source_id) + .fetch_optional(&mut **transaction) + .await? + .ok_or(CatalogError::NotFound { entity: "source" })?; + if actual_revision == expected_revision { + Ok(()) + } else { + Err(CatalogError::RevisionConflict { + scope: "source", + expected: expected_revision, + actual: actual_revision, + }) + } +} + +async fn replace_credential_in_transaction( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + schema_version: u32, + ciphertext: Vec, + expected_revision: i64, + now: i64, +) -> Result { + let revision = sqlx::query_scalar::<_, i64>( + "UPDATE source_credentials SET schema_version = ?, payload_ciphertext = ?, \ + revision = revision + 1, updated_at = ? WHERE source_id = ? AND revision = ? \ + RETURNING revision", + ) + .bind(i64::from(schema_version)) + .bind(ciphertext) + .bind(now) + .bind(source_id) + .bind(expected_revision) + .fetch_optional(&mut **transaction) + .await?; + if let Some(revision) = revision { + return Ok(revision); + } + let actual = + sqlx::query_scalar::<_, i64>("SELECT revision FROM source_credentials WHERE source_id = ?") + .bind(source_id) + .fetch_optional(&mut **transaction) + .await? + .unwrap_or(-1); + Err(CatalogError::RevisionConflict { + scope: "credential", + expected: expected_revision, + actual, + }) +} + +async fn delete_credential_in_transaction( + transaction: &mut Transaction<'_, sqlx::Sqlite>, + source_id: &str, + expected_revision: i64, +) -> Result<(), CatalogError> { + let changed = + sqlx::query("DELETE FROM source_credentials WHERE source_id = ? AND revision = ?") + .bind(source_id) + .bind(expected_revision) + .execute(&mut **transaction) + .await? + .rows_affected(); + if changed == 1 { + return Ok(()); + } + let actual = + sqlx::query_scalar::<_, i64>("SELECT revision FROM source_credentials WHERE source_id = ?") + .bind(source_id) + .fetch_optional(&mut **transaction) + .await? + .unwrap_or(-1); + Err(CatalogError::RevisionConflict { + scope: "credential", + expected: expected_revision, + actual, + }) +} + async fn source_path_snapshot( transaction: &mut Transaction<'_, sqlx::Sqlite>, source_id: &str, @@ -3498,6 +4087,11 @@ mod tests { ToolBinding::OpenapiV1(binding) => serde_json::to_string(binding) .expect("binding should serialize") .len(), + ToolBinding::McpHttpV1(binding) | ToolBinding::McpStdioV1(binding) => { + serde_json::to_string(binding) + .expect("binding should serialize") + .len() + } }; limits.aggregate = snapshot_bytes + binding_bytes - 1; assert!(prepare_snapshot_with_limits(snapshot.clone(), limits).is_ok()); diff --git a/src/cli/client.rs b/src/cli/client.rs new file mode 100644 index 000000000..c20f04223 --- /dev/null +++ b/src/cli/client.rs @@ -0,0 +1,656 @@ +use std::time::{Duration, Instant}; + +use reqwest::{Method, Response, StatusCode, header}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use thiserror::Error; +use url::Url; + +use super::terminal::safe_field; + +const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Clone)] +pub struct GatewayClient { + http: reqwest::Client, + base_url: Url, + token: String, +} + +#[derive(Debug, Error)] +pub enum ClientError { + #[error("the Executor base URL is invalid: {0}")] + InvalidBaseUrl(String), + #[error("EXECUTOR_API_TOKEN or --api-token is required")] + MissingToken, + #[error("Executor is not reachable at {base_url}: {source}")] + Unreachable { + base_url: String, + #[source] + source: reqwest::Error, + }, + #[error("Executor returned an invalid response: {0}")] + InvalidResponse(String), + #[error("Executor returned too much data")] + ResponseTooLarge, + #[error("Executor request failed ({status} {code}): {message}{request_suffix}")] + Api { + status: StatusCode, + code: String, + message: String, + request_suffix: String, + }, +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ApiErrorBody, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiErrorBody { + code: String, + message: String, + request_id: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ApprovalRequired { + pub status: String, + pub approval: PendingApproval, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingApproval { + pub id: String, + pub status: String, + pub revision: i64, + pub path: String, + pub created_at: i64, + pub expires_at: i64, + pub status_url: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalDetail { + pub id: String, + pub status: String, + pub revision: i64, + pub path: String, + pub created_at: i64, + pub updated_at: i64, + pub expires_at: i64, + pub failure_code: Option, + pub result: Option, +} + +pub enum InvokeResponse { + Complete(Value), + ApprovalRequired(ApprovalRequired), +} + +impl GatewayClient { + #[cfg(test)] + pub fn new(base_url: &str, token: Option<&str>) -> Result { + Self::new_with_policy(base_url, token, false) + } + + pub fn new_with_policy( + base_url: &str, + token: Option<&str>, + allow_insecure_http: bool, + ) -> Result { + let token = token + .filter(|token| !token.is_empty()) + .ok_or(ClientError::MissingToken)?; + let base_url = normalized_base_url_with_policy(base_url, allow_insecure_http)?; + let http = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(35)) + .user_agent(concat!("executor-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| ClientError::InvalidResponse(error.to_string()))?; + Ok(Self { + http, + base_url, + token: token.to_owned(), + }) + } + + pub fn dashboard_url(&self) -> Url { + self.base_url.clone() + } + + pub async fn invoke( + &self, + path: &str, + arguments: Value, + idempotency_key: &str, + ) -> Result { + let payload = json!({"path": path, "arguments": arguments}); + let mut retry = false; + let started = Instant::now(); + let response = loop { + let response = self + .request(Method::POST, "api/v1/gateway/tools/invoke")? + .header("idempotency-key", idempotency_key) + .json(&payload) + .send() + .await; + match response { + Ok(response) if response.status() == StatusCode::CONFLICT => { + let retry_after = response + .headers() + .get(header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(1) + .min(2); + let bytes = read_bounded(response).await?; + let envelope = serde_json::from_slice::(&bytes).ok(); + if envelope + .as_ref() + .is_some_and(|envelope| envelope.error.code == "idempotency_in_progress") + && started.elapsed() < Duration::from_secs(60) + { + tokio::time::sleep(Duration::from_secs(retry_after)).await; + continue; + } + return Err(api_error(StatusCode::CONFLICT, envelope)); + } + Ok(response) => break response, + Err(_) if !retry => { + retry = true; + tokio::time::sleep(Duration::from_millis(200)).await; + } + Err(source) => return Err(self.unreachable(source)), + } + }; + if response.status() == StatusCode::ACCEPTED { + return self + .decode_success::(response) + .await + .map(InvokeResponse::ApprovalRequired); + } + self.decode_success(response) + .await + .map(InvokeResponse::Complete) + } + + pub async fn approval(&self, approval_id: &str) -> Result { + let path = approval_path(approval_id)?; + let response = self + .request(Method::GET, &path)? + .send() + .await + .map_err(|source| self.unreachable(source))?; + self.decode_success(response).await + } + + pub async fn cancel_approval( + &self, + approval_id: &str, + expected_revision: i64, + ) -> Result { + let path = approval_path(approval_id)?; + let response = self + .request(Method::DELETE, &path)? + .query(&[("expectedRevision", expected_revision)]) + .send() + .await + .map_err(|source| self.unreachable(source))?; + self.decode_success(response).await + } + + pub async fn search( + &self, + query: &str, + namespace: Option<&str>, + limit: u32, + offset: u32, + ) -> Result { + let response = self + .request(Method::POST, "api/v1/gateway/tools/search")? + .json(&json!({ + "query": query, + "namespace": namespace, + "limit": limit, + "offset": offset, + })) + .send() + .await + .map_err(|source| self.unreachable(source))?; + self.decode_success(response).await + } + + pub async fn describe(&self, path: &str) -> Result { + let response = self + .request(Method::POST, "api/v1/gateway/tools/describe")? + .json(&json!({"path": path})) + .send() + .await + .map_err(|source| self.unreachable(source))?; + self.decode_success(response).await + } + + pub async fn sources(&self) -> Result { + let response = self + .request(Method::GET, "api/v1/gateway/sources")? + .send() + .await + .map_err(|source| self.unreachable(source))?; + self.decode_success(response).await + } + + fn request(&self, method: Method, path: &str) -> Result { + let url = self.base_url.join(path).map_err(|_| { + ClientError::InvalidResponse("could not construct the Executor API URL".to_owned()) + })?; + Ok(self + .http + .request(method, url) + .header(header::AUTHORIZATION, format!("Bearer {}", self.token)) + .header(header::ACCEPT, "application/json")) + } + + async fn decode_success( + &self, + response: Response, + ) -> Result { + let status = response.status(); + let bytes = read_bounded(response).await?; + if !status.is_success() { + let envelope = serde_json::from_slice::(&bytes).ok(); + return Err(api_error(status, envelope)); + } + serde_json::from_slice(&bytes) + .map_err(|error| ClientError::InvalidResponse(error.to_string())) + } + + fn unreachable(&self, source: reqwest::Error) -> ClientError { + ClientError::Unreachable { + base_url: self.base_url.to_string(), + source, + } + } +} + +pub fn normalized_base_url(input: &str) -> Result { + normalized_base_url_with_policy(input, false) +} + +pub(crate) fn normalized_base_url_with_policy( + input: &str, + allow_insecure_http: bool, +) -> Result { + let mut url = + Url::parse(input).map_err(|error| ClientError::InvalidBaseUrl(error.to_string()))?; + if !matches!(url.scheme(), "http" | "https") + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ClientError::InvalidBaseUrl( + "use an http(s) origin without credentials, query, or fragment".to_owned(), + )); + } + if !matches!(url.path(), "" | "/") { + return Err(ClientError::InvalidBaseUrl( + "the base URL must not contain a path".to_owned(), + )); + } + if url.scheme() == "http" && !is_loopback_host(&url) && !allow_insecure_http { + return Err(ClientError::InvalidBaseUrl( + "plaintext HTTP is allowed only for localhost or a loopback IP; use HTTPS".to_owned(), + )); + } + url.set_path("/"); + Ok(url) +} + +fn is_loopback_host(url: &Url) -> bool { + match url.host() { + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +fn approval_path(approval_id: &str) -> Result { + if approval_id.is_empty() + || approval_id.len() > 128 + || !approval_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(ClientError::InvalidResponse( + "approval ID was invalid".to_owned(), + )); + } + Ok(format!("api/v1/gateway/approvals/{approval_id}")) +} + +fn api_error(status: StatusCode, envelope: Option) -> ClientError { + let (code, message, request_suffix) = match envelope { + Some(envelope) => ( + safe_field(&envelope.error.code), + safe_field(&envelope.error.message), + envelope + .error + .request_id + .map(|id| format!(" (request {})", safe_field(&id))) + .unwrap_or_default(), + ), + None => ( + "http_error".to_owned(), + status + .canonical_reason() + .unwrap_or("request failed") + .to_owned(), + String::new(), + ), + }; + ClientError::Api { + status, + code, + message, + request_suffix, + } +} + +async fn read_bounded(mut response: Response) -> Result, ClientError> { + if response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + { + return Err(ClientError::ResponseTooLarge); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| ClientError::InvalidResponse(error.to_string()))? + { + if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(ClientError::ResponseTooLarge); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use axum::{ + Json, Router, + extract::State, + http::{HeaderMap, Uri}, + response::IntoResponse, + routing::{get, post}, + }; + use serde_json::json; + use tokio::sync::oneshot; + + use super::*; + + type CapturedRequestSender = + std::sync::Arc>>>; + + async fn spawn_server(router: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.expect("test server"); + }); + (format!("http://{address}"), task) + } + + #[test] + fn normalizes_clean_origins_and_rejects_secret_bearing_urls() { + assert_eq!( + normalized_base_url("http://localhost:4788") + .expect("valid URL") + .as_str(), + "http://localhost:4788/" + ); + for invalid in [ + "http://token@localhost:4788", + "http://localhost:4788/?token=secret", + "http://localhost:4788/#secret", + "file:///tmp/executor", + "http://localhost:4788/prefix", + "http://example.com:4788", + ] { + assert!(normalized_base_url(invalid).is_err(), "accepted {invalid}"); + } + } + + #[test] + fn requires_a_token_without_putting_it_in_the_dashboard_url() { + assert!(matches!( + GatewayClient::new("http://localhost:4788", None), + Err(ClientError::MissingToken) + )); + let client = GatewayClient::new("http://localhost:4788", Some("secret")).expect("client"); + assert_eq!(client.dashboard_url().as_str(), "http://localhost:4788/"); + assert!(!client.dashboard_url().as_str().contains("secret")); + } + + #[tokio::test] + async fn invocation_uses_auth_header_and_never_a_url_secret() { + async fn invoke( + State(sender): State, + uri: Uri, + headers: HeaderMap, + ) -> impl IntoResponse { + if let Some(sender) = sender.lock().expect("sender lock").take() { + let _ = sender.send((uri, headers)); + } + Json(json!({"ok": true, "data": {"value": 2}})) + } + + let (sender, receiver) = oneshot::channel(); + let state = std::sync::Arc::new(std::sync::Mutex::new(Some(sender))); + let router = Router::new() + .route("/api/v1/gateway/tools/invoke", post(invoke)) + .with_state(state); + let (base_url, task) = spawn_server(router).await; + let client = GatewayClient::new(&base_url, Some("secret-token")).expect("client"); + let result = client + .invoke("tools.math.add", json!({}), "one-command-key") + .await + .expect("invoke"); + assert!(matches!(result, InvokeResponse::Complete(_))); + let (uri, headers) = receiver.await.expect("request capture"); + assert_eq!(uri.path(), "/api/v1/gateway/tools/invoke"); + assert_eq!(uri.query(), None); + assert_eq!( + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer secret-token") + ); + assert_eq!( + headers + .get("idempotency-key") + .and_then(|value| value.to_str().ok()), + Some("one-command-key") + ); + task.abort(); + } + + #[tokio::test] + async fn decodes_approval_poll_cancel_and_revoked_token_errors() { + async fn invoke() -> impl IntoResponse { + ( + StatusCode::ACCEPTED, + Json(json!({ + "status": "approval_required", + "approval": { + "id": "approval-1", + "status": "pending", + "revision": 0, + "path": "tools.mail.send", + "createdAt": 1, + "expiresAt": 60, + "statusUrl": "/api/v1/gateway/approvals/approval-1" + } + })), + ) + } + async fn approval() -> impl IntoResponse { + Json(json!({ + "id": "approval-1", + "status": "succeeded", + "revision": 2, + "path": "tools.mail.send", + "createdAt": 1, + "updatedAt": 2, + "expiresAt": 60, + "failureCode": null, + "result": {"ok": true, "data": {"sent": true}} + })) + } + async fn revoked() -> impl IntoResponse { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": { + "code": "unauthorized", + "message": "The API token is no longer active.", + "requestId": "request-1" + } + })), + ) + } + let router = Router::new() + .route("/api/v1/gateway/tools/invoke", post(invoke)) + .route( + "/api/v1/gateway/approvals/approval-1", + get(approval).delete(approval), + ) + .route("/api/v1/gateway/sources", get(revoked)); + let (base_url, task) = spawn_server(router).await; + let client = GatewayClient::new(&base_url, Some("token")).expect("client"); + let InvokeResponse::ApprovalRequired(pending) = client + .invoke("tools.mail.send", json!({}), "approval-key") + .await + .expect("approval") + else { + panic!("expected approval"); + }; + let detail = client + .approval(&pending.approval.id) + .await + .expect("approval detail"); + assert_eq!(detail.status, "succeeded"); + let cancelled = client + .cancel_approval(&pending.approval.id, detail.revision) + .await + .expect("cancel request"); + assert_eq!(cancelled.id, "approval-1"); + let error = client.sources().await.expect_err("revoked token"); + assert!(matches!( + error, + ClientError::Api { + status: StatusCode::UNAUTHORIZED, + ref code, + .. + } if code == "unauthorized" + )); + assert!(error.to_string().contains("request request-1")); + task.abort(); + } + + #[tokio::test] + async fn reports_when_the_server_is_not_running() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + drop(listener); + let client = + GatewayClient::new(&format!("http://{address}"), Some("token")).expect("client"); + assert!(matches!( + client.sources().await, + Err(ClientError::Unreachable { .. }) + )); + } + + #[test] + fn approval_ids_cannot_redirect_bearer_requests() { + for invalid in [ + "https://attacker.example/x", + "/https://attacker.example/x", + "../other", + "approval?id=secret", + ] { + assert!(approval_path(invalid).is_err(), "accepted {invalid}"); + } + assert_eq!( + approval_path("9d6e9268-197d-4e60-b267-3f2bf85d368f").expect("UUID"), + "api/v1/gateway/approvals/9d6e9268-197d-4e60-b267-3f2bf85d368f" + ); + } + + #[tokio::test] + async fn retries_in_progress_with_the_same_idempotency_key() { + #[derive(Clone, Default)] + struct StateValue { + calls: std::sync::Arc>>, + } + async fn invoke(State(state): State, headers: HeaderMap) -> impl IntoResponse { + let key = headers + .get("idempotency-key") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let count = { + let mut calls = state.calls.lock().expect("calls"); + calls.push(key); + calls.len() + }; + if count < 3 { + return ( + StatusCode::CONFLICT, + [(header::RETRY_AFTER, "0")], + Json(json!({ + "error": { + "code": "idempotency_in_progress", + "message": "Still running", + "requestId": "request-1" + } + })), + ) + .into_response(); + } + Json(json!({"ok": true, "data": {"done": true}})).into_response() + } + let state = StateValue::default(); + let router = Router::new() + .route("/api/v1/gateway/tools/invoke", post(invoke)) + .with_state(state.clone()); + let (base_url, task) = spawn_server(router).await; + let client = GatewayClient::new(&base_url, Some("token")).expect("client"); + let result = client + .invoke("tools.jobs.run", json!({}), "stable-key") + .await + .expect("eventual replay"); + assert!(matches!(result, InvokeResponse::Complete(_))); + assert_eq!( + state.calls.lock().expect("calls").as_slice(), + ["stable-key", "stable-key", "stable-key"] + ); + task.abort(); + } +} diff --git a/src/cli/input.rs b/src/cli/input.rs new file mode 100644 index 000000000..734ad813b --- /dev/null +++ b/src/cli/input.rs @@ -0,0 +1,125 @@ +use std::{fs::File, io::Read, path::Path}; + +use serde_json::{Map, Value}; +use thiserror::Error; + +pub const MAX_ARGUMENT_FILE_BYTES: usize = crate::invocation::MAX_ARGUMENT_BYTES; + +#[derive(Debug, Error)] +pub enum InputError { + #[error("could not read arguments from {path}: {source}")] + Read { + path: String, + #[source] + source: std::io::Error, + }, + #[error("tool arguments exceed the {MAX_ARGUMENT_FILE_BYTES} byte CLI limit")] + TooLarge, + #[error("tool arguments are not valid JSON: {0}")] + InvalidJson(#[source] serde_json::Error), + #[error("tool arguments must be a JSON object")] + NotObject, +} + +pub fn parse_arguments(input: Option<&str>) -> Result { + let Some(input) = input else { + return Ok(Value::Object(Map::new())); + }; + let bytes = match input.strip_prefix('@') { + Some(path) => read_bounded(path)?, + None => { + if input.len() > MAX_ARGUMENT_FILE_BYTES { + return Err(InputError::TooLarge); + } + input.as_bytes().to_vec() + } + }; + let value: Value = serde_json::from_slice(&bytes).map_err(InputError::InvalidJson)?; + if value.is_object() { + Ok(value) + } else { + Err(InputError::NotObject) + } +} + +fn read_bounded(path: &str) -> Result, InputError> { + if path.is_empty() { + return Err(InputError::Read { + path: path.to_owned(), + source: std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the @file path is empty", + ), + }); + } + let mut file = File::open(Path::new(path)).map_err(|source| InputError::Read { + path: path.to_owned(), + source, + })?; + let mut bytes = Vec::new(); + file.by_ref() + .take((MAX_ARGUMENT_FILE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|source| InputError::Read { + path: path.to_owned(), + source, + })?; + if bytes.len() > MAX_ARGUMENT_FILE_BYTES { + return Err(InputError::TooLarge); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use serde_json::json; + + use super::*; + + #[test] + fn parses_inline_file_and_default_objects() { + assert_eq!(parse_arguments(None).expect("default"), json!({})); + assert_eq!( + parse_arguments(Some(r#"{"message":"hello"}"#)).expect("inline"), + json!({"message": "hello"}) + ); + let mut file = tempfile::NamedTempFile::new().expect("temporary file"); + file.write_all(br#"{"from":"file"}"#).expect("write"); + let argument = format!("@{}", file.path().display()); + assert_eq!( + parse_arguments(Some(&argument)).expect("file"), + json!({"from": "file"}) + ); + } + + #[test] + fn rejects_invalid_non_object_and_oversized_inputs() { + assert!(matches!( + parse_arguments(Some("{")), + Err(InputError::InvalidJson(_)) + )); + assert!(matches!( + parse_arguments(Some("[]")), + Err(InputError::NotObject) + )); + let oversized = "x".repeat(MAX_ARGUMENT_FILE_BYTES + 1); + assert!(matches!( + parse_arguments(Some(&oversized)), + Err(InputError::TooLarge) + )); + } + + #[test] + fn rejects_oversized_files_without_unbounded_reads() { + let mut file = tempfile::NamedTempFile::new().expect("temporary file"); + file.write_all(&vec![b'x'; MAX_ARGUMENT_FILE_BYTES + 1]) + .expect("write"); + let argument = format!("@{}", file.path().display()); + assert!(matches!( + parse_arguments(Some(&argument)), + Err(InputError::TooLarge) + )); + } +} diff --git a/src/cli/mcp_bridge.rs b/src/cli/mcp_bridge.rs new file mode 100644 index 000000000..633a9fdcd --- /dev/null +++ b/src/cli/mcp_bridge.rs @@ -0,0 +1,840 @@ +use std::{future::Future, pin::pin, time::Duration}; + +use anyhow::{Context, Result, bail}; +use reqwest::{Response, StatusCode, header}; +use serde_json::Value; +use tokio::{ + io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}, + sync::mpsc, + task::JoinSet, +}; +use url::Url; + +use super::terminal::safe_field; + +const PROTOCOL_VERSION: &str = "2025-11-25"; +const SESSION_HEADER: &str = "mcp-session-id"; +const MAX_MESSAGE_BYTES: usize = crate::invocation::MAX_ARGUMENT_BYTES + 64 * 1024; +const MAX_IN_FLIGHT_REQUESTS: usize = 8; +const MAX_IN_FLIGHT_WITH_CANCELLATION: usize = 16; + +pub async fn run(base_url: &str, token: Option<&str>, allow_insecure_http: bool) -> Result<()> { + let token = token + .filter(|token| !token.is_empty()) + .ok_or_else(|| anyhow::anyhow!("EXECUTOR_API_TOKEN or --api-token is required"))?; + bridge( + base_url, + token, + allow_insecure_http, + tokio::io::stdin(), + tokio::io::stdout(), + shutdown_signal(), + ) + .await +} + +#[cfg(unix)] +async fn shutdown_signal() { + let terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()); + let Ok(mut terminate) = terminate else { + let _ = tokio::signal::ctrl_c().await; + return; + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +async fn bridge( + base_url: &str, + token: &str, + allow_insecure_http: bool, + input: R, + mut output: W, + shutdown: S, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin, + S: Future, +{ + let mut endpoint = + super::client::normalized_base_url_with_policy(base_url, allow_insecure_http)?; + endpoint.set_path("mcp"); + let http = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .build() + .context("could not initialize the MCP HTTP client")?; + let mut session = None; + let result = bridge_session( + &http, + &endpoint, + token, + &mut session, + BufReader::new(input), + &mut output, + shutdown, + ) + .await; + close_session(&http, &endpoint, token, session.as_deref()).await; + result +} + +async fn bridge_session( + http: &reqwest::Client, + endpoint: &Url, + token: &str, + session: &mut Option, + mut input: BufReader, + output: &mut W, + shutdown: S, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin, + S: Future, +{ + let mut shutdown = pin!(shutdown); + let first = tokio::select! { + line = read_line_bounded(&mut input) => line?, + _ = &mut shutdown => return Ok(()), + }; + let Some(first) = first else { + return Ok(()); + }; + let first = decode_stdin_message(&first)?; + let initialize_request = mcp_request(http, endpoint, token, None).json(&first); + let initialize_response = tokio::select! { + response = initialize_request.send() => { + response.context("Executor is not reachable; start `executor server` first")? + } + _ = &mut shutdown => return Ok(()), + }; + *session = response_session_id(&initialize_response)?; + if session.is_none() { + bail!("the first MCP message must initialize a stateful Executor session"); + } + let reply = tokio::select! { + reply = decode_http_response(initialize_response) => reply?, + _ = &mut shutdown => return Ok(()), + }; + write_protocol_reply(output, reply.stdout).await?; + + let initialized = tokio::select! { + line = read_line_bounded(&mut input) => line?, + _ = &mut shutdown => return Ok(()), + }; + let Some(initialized) = initialized else { + return Ok(()); + }; + let initialized = decode_stdin_message(&initialized)?; + if initialized.get("method").and_then(Value::as_str) != Some("notifications/initialized") + || initialized.get("id").is_some() + { + bail!("MCP initialize must be followed by notifications/initialized"); + } + let initialized_reply = tokio::select! { + reply = post_message( + http, + endpoint, + token, + session.as_deref(), + initialized, + ) => reply?, + _ = &mut shutdown => return Ok(()), + }; + write_protocol_reply(output, initialized_reply.stdout).await?; + + let (line_sender, mut lines) = mpsc::channel::>>>(8); + let reader = tokio::spawn(async move { + loop { + let line = read_line_bounded(&mut input).await; + let done = matches!(line, Ok(None) | Err(_)); + if line_sender.send(line).await.is_err() || done { + return; + } + } + }); + let mut requests = JoinSet::new(); + loop { + tokio::select! { + _ = &mut shutdown => { + reader.abort(); + requests.abort_all(); + return Ok(()); + } + line = lines.recv() => match line { + Some(Ok(Some(line))) => { + let message = decode_stdin_message(&line)?; + let is_cancellation = message.get("method").and_then(Value::as_str) + == Some("notifications/cancelled"); + let limit = if is_cancellation { + MAX_IN_FLIGHT_WITH_CANCELLATION + } else { + MAX_IN_FLIGHT_REQUESTS + }; + if requests.len() >= limit { + write_protocol_reply(output, overload_response(&message)?).await?; + continue; + } + let http = http.clone(); + let endpoint = endpoint.clone(); + let token = token.to_owned(); + let session = session.clone().expect("session established above"); + requests.spawn(async move { + post_message(&http, &endpoint, &token, Some(&session), message) + .await + .map(|reply| reply.stdout) + }); + } + Some(Ok(None)) | None => { + reader.abort(); + requests.abort_all(); + return Ok(()); + } + Some(Err(error)) => { + reader.abort(); + requests.abort_all(); + return Err(error); + } + }, + joined = requests.join_next(), if !requests.is_empty() => { + let reply = joined + .expect("a request was active") + .context("MCP forwarding task stopped unexpectedly")??; + write_protocol_reply(output, reply).await?; + } + } + } +} + +struct HttpReply { + stdout: Option>, +} + +async fn post_message( + http: &reqwest::Client, + endpoint: &Url, + token: &str, + session: Option<&str>, + message: Value, +) -> Result { + let response = mcp_request(http, endpoint, token, session) + .json(&message) + .send() + .await + .context("Executor is not reachable; start `executor server` first")?; + decode_http_response(response).await +} + +fn response_session_id(response: &Response) -> Result> { + let response_session = response + .headers() + .get(SESSION_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + if response_session.as_ref().is_some_and(|session| { + session.is_empty() + || session.len() > 128 + || !session.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) + }) { + bail!("Executor returned an invalid MCP session ID"); + } + Ok(response_session) +} + +async fn decode_http_response(response: Response) -> Result { + let _response_session = response_session_id(&response)?; + let status = response.status(); + let body = tokio::time::timeout( + Duration::from_secs(11 * 60), + read_response_bounded(response), + ) + .await + .context("Executor MCP response timed out")??; + if !status.is_success() { + let detail = serde_json::from_slice::(&body) + .ok() + .and_then(|value| { + value + .pointer("/error/message") + .and_then(Value::as_str) + .map(safe_field) + }) + .unwrap_or_else(|| { + status + .canonical_reason() + .unwrap_or("MCP request failed") + .to_owned() + }); + bail!("Executor MCP request failed ({status}): {detail}"); + } + let stdout = if status == StatusCode::ACCEPTED || body.is_empty() { + None + } else { + let response: Value = + serde_json::from_slice(&body).context("Executor returned invalid MCP JSON-RPC data")?; + Some(serde_json::to_vec(&response).context("could not encode the MCP response")?) + }; + Ok(HttpReply { stdout }) +} + +fn decode_stdin_message(line: &[u8]) -> Result { + let message: Value = + serde_json::from_slice(line).context("stdin contained an invalid MCP JSON-RPC message")?; + if !message.is_object() { + bail!("stdin MCP messages must be JSON objects"); + } + Ok(message) +} + +fn overload_response(message: &Value) -> Result>> { + let Some(id) = message.get("id") else { + return Ok(None); + }; + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": "Executor CLI MCP bridge is busy" + } + }); + serde_json::to_vec(&response) + .map(Some) + .context("could not encode the MCP overload response") +} + +async fn write_protocol_reply( + output: &mut (impl AsyncWrite + Unpin), + reply: Option>, +) -> Result<()> { + let Some(reply) = reply else { + return Ok(()); + }; + output.write_all(&reply).await?; + output.write_all(b"\n").await?; + output.flush().await?; + Ok(()) +} + +fn mcp_request( + http: &reqwest::Client, + endpoint: &Url, + token: &str, + session: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = http + .post(endpoint.clone()) + .bearer_auth(token) + .header(header::ACCEPT, "application/json, text/event-stream") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(session) = session { + request = request + .header(SESSION_HEADER, session) + .header("mcp-protocol-version", PROTOCOL_VERSION); + } + request +} + +async fn close_session(http: &reqwest::Client, endpoint: &Url, token: &str, session: Option<&str>) { + let Some(session) = session else { + return; + }; + let _ = http + .delete(endpoint.clone()) + .bearer_auth(token) + .header(SESSION_HEADER, session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .timeout(Duration::from_secs(2)) + .send() + .await; +} + +async fn read_line_bounded(input: &mut (impl AsyncBufRead + Unpin)) -> Result>> { + let mut line = Vec::new(); + loop { + let available = input.fill_buf().await?; + if available.is_empty() { + if line.is_empty() { + return Ok(None); + } + return Ok(Some(line)); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let payload_take = newline.unwrap_or(available.len()); + let take = newline.map_or(available.len(), |position| position + 1); + if line.len().saturating_add(payload_take) > MAX_MESSAGE_BYTES { + bail!("stdin MCP message exceeds the {MAX_MESSAGE_BYTES} byte limit"); + } + line.extend_from_slice(&available[..take]); + input.consume(take); + if newline.is_some() { + line.pop(); + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(Some(line)); + } + } +} + +async fn read_response_bounded(mut response: Response) -> Result> { + if response + .content_length() + .is_some_and(|length| length > MAX_MESSAGE_BYTES as u64) + { + bail!("Executor MCP response exceeds the {MAX_MESSAGE_BYTES} byte limit"); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len().saturating_add(chunk.len()) > MAX_MESSAGE_BYTES { + bail!("Executor MCP response exceeds the {MAX_MESSAGE_BYTES} byte limit"); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }; + + use axum::{ + Json, Router, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + }; + use serde_json::json; + use tokio::io::AsyncWriteExt; + + use super::*; + + type CapturedRequests = Arc, Option)>>>; + + #[derive(Clone, Default)] + struct TestState { + deletes: Arc>, + requests: CapturedRequests, + hold_calls: Arc, + release_call: Arc, + cancellation_seen: Arc, + initialized: Arc, + } + + async fn post_mcp( + State(state): State, + headers: HeaderMap, + Json(body): Json, + ) -> impl axum::response::IntoResponse { + let auth = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let session = headers + .get(SESSION_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + state + .requests + .lock() + .expect("requests") + .push((auth, session)); + if body.get("method") == Some(&json!("tools/call")) + && state.hold_calls.load(Ordering::SeqCst) + { + state.release_call.notified().await; + } + if body.get("method") == Some(&json!("notifications/cancelled")) { + state.cancellation_seen.store(true, Ordering::SeqCst); + } + if body.get("method") == Some(&json!("initialize")) { + let mut response = Json(json!({ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"protocolVersion": PROTOCOL_VERSION} + })) + .into_response(); + response + .headers_mut() + .insert(SESSION_HEADER, "session-1".parse().expect("header")); + response + } else if body.get("method") == Some(&json!("notifications/initialized")) { + state.initialized.store(true, Ordering::SeqCst); + StatusCode::ACCEPTED.into_response() + } else if !state.initialized.load(Ordering::SeqCst) { + StatusCode::BAD_REQUEST.into_response() + } else if body.get("id").is_none() { + StatusCode::ACCEPTED.into_response() + } else { + Json(json!({"jsonrpc": "2.0", "id": body["id"], "result": {}})).into_response() + } + } + + async fn delete_mcp(State(state): State) -> StatusCode { + *state.deletes.lock().expect("deletes") += 1; + StatusCode::NO_CONTENT + } + + #[tokio::test] + async fn bridge_keeps_protocol_stdout_clean_and_closes_the_session() { + let state = TestState::default(); + let router = Router::new() + .route("/mcp", post(post_mcp).delete(delete_mcp)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.expect("server"); + }); + let (mut writer, reader) = tokio::io::duplex(4096); + writer + .write_all( + concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n", + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}\n" + ) + .as_bytes(), + ) + .await + .expect("input"); + let output = VecWriter::default(); + let captured = output.bytes.clone(); + let bridge_task = tokio::spawn(async move { + bridge( + &format!("http://{address}"), + "secret-token", + false, + reader, + output, + std::future::pending(), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if captured + .lock() + .expect("output") + .iter() + .filter(|byte| **byte == b'\n') + .count() + == 2 + { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("responses"); + writer.shutdown().await.expect("EOF"); + bridge_task.await.expect("bridge task").expect("bridge"); + let output = String::from_utf8(captured.lock().expect("output").clone()).expect("UTF-8"); + let lines = output.lines().collect::>(); + assert_eq!(lines.len(), 2, "notifications must not produce stdout"); + assert!( + lines + .iter() + .all(|line| serde_json::from_str::(line).is_ok()) + ); + let requests = state.requests.lock().expect("requests"); + assert_eq!(requests[0], (Some("Bearer secret-token".to_owned()), None)); + assert_eq!( + requests[1], + ( + Some("Bearer secret-token".to_owned()), + Some("session-1".to_owned()) + ) + ); + drop(requests); + assert_eq!(*state.deletes.lock().expect("deletes"), 1); + server.abort(); + } + + #[tokio::test] + async fn bridge_requires_a_running_server_without_polluting_stdout() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + drop(listener); + let input = std::io::Cursor::new( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n".to_vec(), + ); + let output = VecWriter::default(); + let captured = output.bytes.clone(); + let error = bridge( + &format!("http://{address}"), + "token", + false, + input, + output, + std::future::pending(), + ) + .await + .expect_err("server is stopped"); + assert!(error.to_string().contains("start `executor server` first")); + assert!(captured.lock().expect("output").is_empty()); + } + + #[tokio::test] + async fn bridge_closes_session_after_malformed_post_initialize_input() { + let state = TestState::default(); + let router = Router::new() + .route("/mcp", post(post_mcp).delete(delete_mcp)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.expect("server"); + }); + let input = std::io::Cursor::new( + concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n", + "not-json\n" + ) + .as_bytes() + .to_vec(), + ); + let error = bridge( + &format!("http://{address}"), + "token", + false, + input, + VecWriter::default(), + std::future::pending(), + ) + .await + .expect_err("malformed input"); + assert!(error.to_string().contains("invalid MCP JSON-RPC")); + assert_eq!(*state.deletes.lock().expect("deletes"), 1); + server.abort(); + } + + #[tokio::test] + async fn bridge_shutdown_signal_closes_session_without_stdout_noise() { + let state = TestState::default(); + let router = Router::new() + .route("/mcp", post(post_mcp).delete(delete_mcp)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.expect("server"); + }); + let (mut writer, input) = tokio::io::duplex(4096); + writer + .write_all( + concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n", + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + ) + .as_bytes(), + ) + .await + .expect("handshake"); + let output = VecWriter::default(); + let captured = output.bytes.clone(); + let (shutdown, shutdown_requested) = tokio::sync::oneshot::channel(); + let bridge_task = tokio::spawn(async move { + bridge( + &format!("http://{address}"), + "token", + false, + input, + output, + async { + let _ = shutdown_requested.await; + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if state.initialized.load(Ordering::SeqCst) { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("session initialization"); + shutdown.send(()).expect("shutdown receiver"); + bridge_task.await.expect("bridge task").expect("bridge"); + assert_eq!(*state.deletes.lock().expect("deletes"), 1); + let output = String::from_utf8(captured.lock().expect("output").clone()).expect("UTF-8"); + assert_eq!( + output.lines().count(), + 1, + "only initialize may reach stdout" + ); + server.abort(); + } + + #[tokio::test] + async fn bridge_forwards_cancellation_while_another_request_is_open() { + let state = TestState::default(); + state.hold_calls.store(true, Ordering::SeqCst); + let router = Router::new() + .route("/mcp", post(post_mcp).delete(delete_mcp)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.expect("server"); + }); + let (mut writer, input) = tokio::io::duplex(4096); + writer + .write_all(concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n", + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{}}\n", + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",\"params\":{\"requestId\":2}}\n" + ) + .as_bytes()) + .await + .expect("input"); + let bridge_task = tokio::spawn(async move { + bridge( + &format!("http://{address}"), + "token", + false, + input, + VecWriter::default(), + std::future::pending(), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if state.cancellation_seen.load(Ordering::SeqCst) { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cancellation notification should not wait for the tool call"); + state.release_call.notify_waiters(); + writer.shutdown().await.expect("EOF"); + bridge_task.await.expect("bridge task").expect("bridge"); + server.abort(); + } + + #[tokio::test] + async fn overload_does_not_block_the_reserved_cancellation_lane() { + let state = TestState::default(); + state.hold_calls.store(true, Ordering::SeqCst); + let router = Router::new() + .route("/mcp", post(post_mcp).delete(delete_mcp)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.expect("server"); + }); + let (mut writer, input) = tokio::io::duplex(16 * 1024); + writer + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + ) + .await + .expect("handshake"); + for id in 2..=10 { + writer + .write_all( + format!( + "{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"tools/call\",\"params\":{{}}}}\n" + ) + .as_bytes(), + ) + .await + .expect("call"); + } + writer + .write_all( + b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",\"params\":{\"requestId\":2}}\n", + ) + .await + .expect("cancellation"); + let bridge_task = tokio::spawn(async move { + bridge( + &format!("http://{address}"), + "token", + false, + input, + VecWriter::default(), + std::future::pending(), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if state.cancellation_seen.load(Ordering::SeqCst) { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cancellation must bypass the saturated regular lane"); + writer.shutdown().await.expect("EOF"); + bridge_task.await.expect("bridge task").expect("bridge"); + server.abort(); + } + + #[derive(Clone, Default)] + struct VecWriter { + bytes: Arc>>, + } + + impl AsyncWrite for VecWriter { + fn poll_write( + self: std::pin::Pin<&mut Self>, + _context: &mut std::task::Context<'_>, + buffer: &[u8], + ) -> std::task::Poll> { + self.bytes.lock().expect("output").extend_from_slice(buffer); + std::task::Poll::Ready(Ok(buffer.len())) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _context: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _context: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 000000000..276524c8f --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,447 @@ +mod client; +mod input; +mod mcp_bridge; +mod output; +mod terminal; + +use std::{ + io, + process::{Command, Stdio}, + time::Duration, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Subcommand}; +use serde_json::Value; +use tokio::time::sleep; +use uuid::Uuid; + +pub use client::normalized_base_url; +use client::{GatewayClient, InvokeResponse}; +use input::parse_arguments; + +pub const DEFAULT_BASE_URL: &str = crate::DEFAULT_ORIGIN; + +#[derive(Clone, Debug)] +pub struct ConnectionOptions { + pub base_url: String, + pub api_token: Option, + pub json: bool, + pub allow_insecure_http: bool, +} + +#[derive(Args, Debug)] +pub struct CallArgs { + #[arg(required = true, num_args = 1.., value_name = "PATH [PATH] [JSON-OR-@FILE]")] + pub values: Vec, +} + +#[derive(Args, Debug)] +pub struct ToolsArgs { + #[command(subcommand)] + pub command: ToolsCommand, +} + +#[derive(Subcommand, Debug)] +pub enum ToolsCommand { + Search(SearchArgs), + Describe(DescribeArgs), + Sources, +} + +#[derive(Args, Debug)] +pub struct SearchArgs { + pub query: String, + #[arg(long)] + pub namespace: Option, + #[arg(long, default_value_t = 12, value_parser = clap::value_parser!(u32).range(1..=100))] + pub limit: u32, + #[arg(long, default_value_t = 0)] + pub offset: u32, +} + +#[derive(Args, Debug)] +pub struct DescribeArgs { + #[arg(required = true, num_args = 1..=2, value_name = "PATH [PATH]")] + pub path: Vec, +} + +pub async fn call(options: ConnectionOptions, args: CallArgs) -> Result<()> { + let (path, input) = split_call_values(&args.values)?; + let arguments = parse_arguments(input).context("invalid tool arguments")?; + let client = client(&options)?; + let idempotency_key = Uuid::new_v4().to_string(); + match client.invoke(&path, arguments, &idempotency_key).await? { + InvokeResponse::Complete(value) => print_call(&value, options.json)?, + InvokeResponse::ApprovalRequired(pending) => { + let approval_url = approval_dashboard_url(&client, &pending.approval.id)?; + eprintln!( + "Approval required for {}.", + terminal::safe_field(&pending.approval.path) + ); + eprintln!("Review it at: {approval_url}"); + let _ = try_open(&approval_url); + let value = wait_for_approval( + &client, + &pending.approval.id, + pending.approval.revision, + pending.approval.expires_at, + ) + .await?; + print_call(&value, options.json)?; + } + } + Ok(()) +} + +pub async fn tools(options: ConnectionOptions, args: ToolsArgs) -> Result<()> { + let client = client(&options)?; + let value = match &args.command { + ToolsCommand::Search(args) => { + client + .search( + &args.query, + args.namespace.as_deref(), + args.limit, + args.offset, + ) + .await? + } + ToolsCommand::Describe(args) => { + let path = normalize_path(&args.path)?; + client.describe(&path).await? + } + ToolsCommand::Sources => client.sources().await?, + }; + if options.json { + output::write_json(io::stdout().lock(), &value)?; + return Ok(()); + } + match &args.command { + ToolsCommand::Search(_) => output::write_search_human(io::stdout().lock(), &value)?, + ToolsCommand::Describe(_) => output::write_describe_human(io::stdout().lock(), &value)?, + ToolsCommand::Sources => output::write_sources_human(io::stdout().lock(), &value)?, + } + Ok(()) +} + +pub async fn mcp(options: ConnectionOptions) -> Result<()> { + if options.json { + bail!("--json is not valid with the MCP stdio bridge"); + } + mcp_bridge::run( + &options.base_url, + options.api_token.as_deref(), + options.allow_insecure_http, + ) + .await +} + +pub fn open(options: &ConnectionOptions) -> Result<()> { + let url = + client::normalized_base_url_with_policy(&options.base_url, options.allow_insecure_http)?; + println!("Opening {url}"); + try_open(url.as_str()) + .with_context(|| format!("could not open a browser; copy this URL instead: {url}")) +} + +fn client(options: &ConnectionOptions) -> Result { + GatewayClient::new_with_policy( + &options.base_url, + options.api_token.as_deref(), + options.allow_insecure_http, + ) + .map_err(Into::into) +} + +fn split_call_values(values: &[String]) -> Result<(String, Option<&str>)> { + let (path_values, input) = match values { + [] => bail!("a tool path is required"), + [path] => (std::slice::from_ref(path), None), + [path, input] if path.contains('.') || path.contains('/') || looks_like_json(input) => { + (std::slice::from_ref(path), Some(input.as_str())) + } + [_, _] => (&values[..2], None), + [source, tool, input] => { + let _ = (source, tool); + (&values[..2], Some(input.as_str())) + } + _ => bail!("use a dotted path, or two path segments, followed by one JSON argument"), + }; + Ok((normalize_path(path_values)?, input)) +} + +fn looks_like_json(value: &str) -> bool { + let value = value.trim_start(); + value.starts_with('{') || value.starts_with('@') +} + +pub fn normalize_path(values: &[String]) -> Result { + let segments: Vec<&str> = match values { + [path] => path + .strip_prefix("tools.") + .unwrap_or(path) + .split(['.', '/']) + .collect(), + [source, tool] => vec![source, tool], + _ => bail!("tool paths must be dotted or contain exactly two segments"), + }; + if segments.len() != 2 || segments.iter().any(|segment| !valid_segment(segment)) { + bail!("tool paths must look like source.tool or source tool"); + } + Ok(format!("tools.{}.{}", segments[0], segments[1])) +} + +fn valid_segment(segment: &str) -> bool { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|character| character.is_ascii_lowercase()) + && characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }) +} + +fn approval_dashboard_url(client: &GatewayClient, approval_id: &str) -> Result { + let mut url = client.dashboard_url(); + url.set_path("approvals"); + url.query_pairs_mut().append_pair("approval", approval_id); + Ok(url.to_string()) +} + +async fn wait_for_approval( + client: &GatewayClient, + approval_id: &str, + mut revision: i64, + mut expires_at: i64, +) -> Result { + let interrupt = tokio::signal::ctrl_c(); + tokio::pin!(interrupt); + loop { + if unix_timestamp() >= expires_at.saturating_add(2) { + bail!("tool approval expired while waiting"); + } + let detail = tokio::select! { + detail = client.approval(approval_id) => detail?, + interrupt = &mut interrupt => { + interrupt.context("could not listen for Ctrl-C")?; + return cancel_waiting_approval(client, approval_id, revision).await; + } + }; + revision = detail.revision; + expires_at = detail.expires_at; + match detail.status.as_str() { + "pending" | "approved" | "executing" => { + tokio::select! { + _ = sleep(Duration::from_millis(750)) => {} + interrupt = &mut interrupt => { + interrupt.context("could not listen for Ctrl-C")?; + return cancel_waiting_approval(client, approval_id, revision).await; + } + } + } + "succeeded" => { + return detail + .result + .ok_or_else(|| anyhow::anyhow!("approval succeeded without a tool result")); + } + "failed" if detail.result.is_some() => { + return Ok(detail.result.expect("checked result")); + } + "denied" | "canceled" | "expired" | "failed" | "stale" | "interrupted" => { + let code = detail.failure_code.unwrap_or_else(|| detail.status.clone()); + bail!( + "tool call ended with status {} ({})", + terminal::safe_field(&detail.status), + terminal::safe_field(&code) + ); + } + status => bail!( + "Executor returned an unknown approval status: {}", + terminal::safe_field(status) + ), + } + } +} + +async fn cancel_waiting_approval( + client: &GatewayClient, + approval_id: &str, + revision: i64, +) -> Result { + let cancellation = tokio::time::timeout( + Duration::from_secs(5), + client.cancel_approval(approval_id, revision), + ) + .await; + match cancellation { + Err(_) => bail!("interrupted while waiting for approval; cancellation timed out"), + Ok(Ok(_)) => bail!("approval canceled"), + Ok(Err(cancel_error)) => { + match tokio::time::timeout(Duration::from_secs(5), client.approval(approval_id)).await { + Ok(Ok(detail)) + if matches!( + detail.status.as_str(), + "denied" | "canceled" | "expired" | "failed" | "stale" | "interrupted" + ) => + { + bail!( + "tool call ended with status {}", + terminal::safe_field(&detail.status) + ) + } + Ok(Ok(detail)) if detail.status == "succeeded" && detail.result.is_some() => { + Ok(detail.result.expect("checked result")) + } + _ => { + bail!( + "interrupted while waiting for approval; cancellation failed: {cancel_error}" + ) + } + } + } + } +} + +fn unix_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn print_call(value: &Value, json_output: bool) -> Result<()> { + if json_output { + output::write_json(io::stdout().lock(), value)?; + } else { + output::write_call_human(io::stdout().lock(), value)?; + } + Ok(()) +} + +fn try_open(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut command = Command::new("open"); + #[cfg(target_os = "linux")] + let mut command = Command::new("xdg-open"); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + bail!("opening a browser is only supported on Linux and macOS"); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + command + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("the platform browser opener is unavailable")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use axum::{Json, Router, routing::get}; + use serde_json::json; + + use super::*; + + #[test] + fn accepts_dotted_prefixed_slash_and_segmented_paths() { + for (input, expected) in [ + (vec!["github.issues_create"], "tools.github.issues_create"), + ( + vec!["tools.github.issues_create"], + "tools.github.issues_create", + ), + (vec!["github/issues_create"], "tools.github.issues_create"), + ( + vec!["github", "issues_create"], + "tools.github.issues_create", + ), + ] { + let input = input.into_iter().map(str::to_owned).collect::>(); + assert_eq!(normalize_path(&input).expect("valid path"), expected); + } + assert!(normalize_path(&["Github.bad".to_owned()]).is_err()); + assert!(normalize_path(&["three.part.path".to_owned()]).is_err()); + } + + #[test] + fn splits_call_path_and_optional_arguments_without_ambiguity() { + let values = vec!["github".to_owned(), "issues_create".to_owned()]; + let (path, input) = split_call_values(&values).expect("segmented call"); + assert_eq!(path, "tools.github.issues_create"); + assert_eq!(input, None); + + let values = vec!["github.issues_create".to_owned(), "{}".to_owned()]; + let (path, input) = split_call_values(&values).expect("dotted call"); + assert_eq!(path, "tools.github.issues_create"); + assert_eq!(input, Some("{}")); + } + + #[test] + fn approval_dashboard_url_contains_no_api_token() { + let client = + GatewayClient::new("http://localhost:4788", Some("top-secret")).expect("client"); + let url = approval_dashboard_url(&client, "approval-1").expect("URL"); + assert_eq!(url, "http://localhost:4788/approvals?approval=approval-1"); + assert!(!url.contains("top-secret")); + } + + #[test] + fn approval_json_shape_is_preserved() { + let value = json!({"status": "succeeded", "result": {"ok": true}}); + let mut output = Vec::new(); + output::write_json(&mut output, &value).expect("output"); + assert_eq!( + serde_json::from_slice::(&output).expect("JSON"), + value + ); + } + + #[tokio::test] + async fn approval_wait_preserves_succeeded_and_failed_tool_results() { + async fn succeeded() -> Json { + Json(json!({ + "id": "approval-1", "status": "succeeded", "revision": 2, + "path": "tools.mail.send", "createdAt": 1, + "updatedAt": 2, "expiresAt": unix_timestamp() + 60, + "failureCode": null, + "result": {"ok": true, "data": {"sent": true}} + })) + } + async fn failed() -> Json { + Json(json!({ + "id": "approval-2", "status": "failed", "revision": 2, + "path": "tools.mail.send", "createdAt": 1, + "updatedAt": 2, "expiresAt": unix_timestamp() + 60, + "failureCode": "upstream_failed", + "result": {"ok": false, "data": null, "error": {"code": "upstream_failed", "message": "Nope"}} + })) + } + let router = Router::new() + .route("/api/v1/gateway/approvals/approval-1", get(succeeded)) + .route("/api/v1/gateway/approvals/approval-2", get(failed)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let address = listener.local_addr().expect("address"); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.expect("server"); + }); + let client = + GatewayClient::new(&format!("http://{address}"), Some("token")).expect("client"); + let success = wait_for_approval(&client, "approval-1", 0, unix_timestamp() + 60) + .await + .expect("success"); + assert_eq!(success["data"]["sent"], true); + let failure = wait_for_approval(&client, "approval-2", 0, unix_timestamp() + 60) + .await + .expect("public failure result"); + assert_eq!(failure["error"]["code"], "upstream_failed"); + server.abort(); + } +} diff --git a/src/cli/output.rs b/src/cli/output.rs new file mode 100644 index 000000000..1cbd83c04 --- /dev/null +++ b/src/cli/output.rs @@ -0,0 +1,145 @@ +use std::io::{self, Write}; + +use serde_json::Value; + +use super::terminal::safe_field; + +pub fn write_json(mut output: impl Write, value: &Value) -> io::Result<()> { + serde_json::to_writer_pretty(&mut output, value)?; + writeln!(output) +} + +pub fn write_call_human(output: impl Write, value: &Value) -> io::Result<()> { + if value.get("ok") == Some(&Value::Bool(true)) + && let Some(data) = value.get("data") + { + return write_json(output, data); + } + write_json(output, value) +} + +pub fn write_search_human(mut output: impl Write, value: &Value) -> io::Result<()> { + let Some(items) = value.get("items").and_then(Value::as_array) else { + return write_json(output, value); + }; + if items.is_empty() { + return writeln!(output, "No tools found."); + } + for item in items { + let path = item + .get("path") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let approval = item + .get("requiresApproval") + .and_then(Value::as_bool) + .unwrap_or(false); + writeln!( + output, + "{}{}", + safe_field(path), + if approval { + " [approval required]" + } else { + "" + } + )?; + if let Some(description) = item.get("description").and_then(Value::as_str) { + writeln!(output, " {}", safe_field(description))?; + } + } + if value + .get("hasMore") + .and_then(Value::as_bool) + .unwrap_or(false) + { + writeln!( + output, + "More results are available. Use --offset to continue." + )?; + } + Ok(()) +} + +pub fn write_describe_human(mut output: impl Write, value: &Value) -> io::Result<()> { + let path = value + .get("path") + .and_then(Value::as_str) + .unwrap_or("unknown"); + writeln!(output, "{}", safe_field(path))?; + if let Some(description) = value.get("description").and_then(Value::as_str) { + writeln!(output, "{}", safe_field(description))?; + } + if let Some(mode) = value.pointer("/effectiveMode/mode").and_then(Value::as_str) { + writeln!(output, "Mode: {}", safe_field(mode))?; + } + if let Some(input) = value.get("inputSchema") { + writeln!(output, "Input:")?; + serde_json::to_writer_pretty(&mut output, input)?; + writeln!(output)?; + } + Ok(()) +} + +pub fn write_sources_human(mut output: impl Write, value: &Value) -> io::Result<()> { + let Some(sources) = value.get("sources").and_then(Value::as_array) else { + return write_json(output, value); + }; + if sources.is_empty() { + return writeln!(output, "No sources connected."); + } + for source in sources { + let slug = source + .get("slug") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let display_name = source + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(slug); + let kind = source + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let count = source.get("toolCount").and_then(Value::as_i64).unwrap_or(0); + writeln!( + output, + "{} {} {} {count} tools", + safe_field(slug), + safe_field(display_name), + safe_field(kind) + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn human_and_json_outputs_are_stable_and_secret_free() { + let value = json!({ + "items": [{ + "path": "tools.github.issue_create", + "description": "Create an issue", + "requiresApproval": true + }], + "hasMore": true + }); + let mut human = Vec::new(); + write_search_human(&mut human, &value).expect("human output"); + let human = String::from_utf8(human).expect("UTF-8"); + assert!(human.contains("tools.github.issue_create [approval required]")); + assert!(human.contains("Use --offset")); + + let mut json_output = Vec::new(); + write_json(&mut json_output, &value).expect("JSON output"); + assert_eq!( + serde_json::from_slice::(&json_output).expect("valid JSON"), + value + ); + } +} diff --git a/src/cli/terminal.rs b/src/cli/terminal.rs new file mode 100644 index 000000000..fe0e07cb3 --- /dev/null +++ b/src/cli/terminal.rs @@ -0,0 +1,42 @@ +const MAX_TERMINAL_FIELD_CHARACTERS: usize = 2_000; + +pub fn safe_field(value: &str) -> String { + let mut output = String::new(); + for character in value.chars().take(MAX_TERMINAL_FIELD_CHARACTERS) { + if character.is_control() + || matches!( + character, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) + { + output.push(' '); + } else { + output.push(character); + } + } + if value.chars().count() > MAX_TERMINAL_FIELD_CHARACTERS { + output.push_str("..."); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removes_terminal_controls_and_bounds_fields() { + assert_eq!( + safe_field("hello\n\u{1b}]52;c;evil\u{7}world"), + "hello ]52;c;evil world" + ); + let long = "x".repeat(MAX_TERMINAL_FIELD_CHARACTERS + 1); + let safe = safe_field(&long); + assert!(safe.ends_with("...")); + assert_eq!(safe.len(), MAX_TERMINAL_FIELD_CHARACTERS + 3); + } +} diff --git a/src/database.rs b/src/database.rs index d9d5c5203..6fa85a3c0 100644 --- a/src/database.rs +++ b/src/database.rs @@ -31,6 +31,8 @@ pub enum DatabaseError { Approval(#[from] ApprovalError), #[error(transparent)] Crypto(#[from] CryptoError), + #[error(transparent)] + McpTemplates(#[from] crate::mcp::upstream::stdio::StdioTemplateError), #[error("could not configure the SQLite database: {0}")] Configuration(#[source] sqlx::Error), #[error("could not run embedded SQLite migrations: {0}")] diff --git a/src/invocation/idempotency.rs b/src/invocation/idempotency.rs index 2a5b8230c..7f5b1dc96 100644 --- a/src/invocation/idempotency.rs +++ b/src/invocation/idempotency.rs @@ -637,6 +637,16 @@ impl GatewayIdempotencyStore { row.map(|row| row.record()).transpose() } + pub(crate) async fn state( + &self, + id: &str, + ) -> Result, IdempotencyError> { + validate_identifier(id, 128)?; + self.get(id) + .await + .map(|record| record.map(|record| record.state)) + } + fn key_digest(&self, owner: IdempotencyOwner<'_>, key: &str) -> [u8; 32] { let mut input = Vec::with_capacity(owner.owner_api_token_id.len() + key.len() + 16); append_field(&mut input, owner.owner_api_token_id.as_bytes()); @@ -1095,6 +1105,19 @@ mod tests { .expect("path mismatch"), IdempotencyClaim::Mismatch )); + let changed_route = IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: "owner-a", + }, + key: "retry-key", + route: "mcp tools/call changed", + callable_path: "source.tool", + arguments: &first_arguments, + }; + assert!(matches!( + store.claim(changed_route).await.expect("route mismatch"), + IdempotencyClaim::Mismatch + )); assert!(matches!( store .claim(request( @@ -1192,6 +1215,63 @@ mod tests { )); } + #[tokio::test] + async fn startup_never_replays_an_uncertain_mcp_approval_execution() { + let (_directory, store, _clock) = store().await; + let arguments = json!({"sideEffect": true}); + let claim = store + .claim(request( + "owner-a", + "mcp-ask-crash", + "source.tool", + &arguments, + )) + .await + .expect("claim"); + let IdempotencyClaim::Fresh(record) = claim else { + panic!("fresh claim expected"); + }; + sqlx::query( + "INSERT INTO approvals ( \ + id, execution_id, call_id, worker_generation, actor_kind, actor_id, \ + actor_api_token_id, surface, source_id, tool_id, callable_path_snapshot, \ + mode_provenance, source_revision, catalog_revision, tool_revision, \ + binding_revision, arguments_digest, arguments_ciphertext, \ + redacted_arguments_ciphertext, input_schema_ciphertext, \ + invocation_snapshot_ciphertext, status, created_at, updated_at, expires_at, \ + execution_started_at \ + ) VALUES ( \ + 'mcp-ask-approval', ?, 'gateway', 0, 'api_token', 'owner-a', \ + 'owner-a', 'mcp', 'source-1', 'tool-1', 'tools.source.tool', \ + 'intrinsic', 0, 0, 0, 0, zeroblob(32), zeroblob(42), zeroblob(42), \ + zeroblob(42), zeroblob(42), 'executing', 1, 1, 601, 1 \ + )", + ) + .bind(idempotency_execution_id(&record.id)) + .execute(&store.pool) + .await + .expect("MCP Ask approval fixture"); + store + .mark_executing(&record.id) + .await + .expect("approved call crosses execution boundary"); + + let recovery = store.recover_startup().await.expect("startup recovery"); + assert_eq!(recovery.indeterminate_executions, 1); + assert!(matches!( + store + .claim(request( + "owner-a", + "mcp-ask-crash", + "source.tool", + &arguments, + )) + .await + .expect("post-crash retry"), + IdempotencyClaim::Indeterminate(_) + )); + } + #[tokio::test] async fn abandoned_reservations_can_be_released_without_reusing_an_active_claim() { let (_directory, store, _clock) = store().await; diff --git a/src/invocation/mod.rs b/src/invocation/mod.rs index 62be01253..da13cc1a8 100644 --- a/src/invocation/mod.rs +++ b/src/invocation/mod.rs @@ -1,6 +1,7 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, future::Future, + pin::Pin, sync::atomic::{AtomicBool, Ordering}, sync::{Arc, Mutex}, time::{Duration, Instant}, @@ -125,11 +126,44 @@ pub struct ToolCallService { discovery_slots: Arc, execution_slots: Arc, in_flight_approvals: Arc>>, + in_flight_mcp_settlements: Arc>>, deferred_execution_cancellations: Arc>>, execution_stopping_flags: Arc>>>, + #[cfg(test)] + approval_cancellation_race_hook: Arc>>>, + #[cfg(test)] + approved_execution_hook: Arc>>>, + #[cfg(test)] + cancel_execution_hook: Arc>>>, background_tasks: TaskTracker, } +#[cfg(test)] +#[derive(Default)] +struct ApprovalCancellationRaceHook { + decision_read_acquired: Notify, + cancellation_write_queued: Notify, + release_decision: Notify, +} + +#[cfg(test)] +#[derive(Default)] +struct ApprovedExecutionHook { + started: Notify, + release: Notify, + dispatches: std::sync::atomic::AtomicUsize, +} + +#[cfg(test)] +#[derive(Default)] +struct CancelExecutionHook { + failures: std::sync::atomic::AtomicUsize, + block_retries: AtomicBool, + retry_waiters: std::sync::atomic::AtomicUsize, + retry_waiting: Notify, + release_retries: Notify, +} + #[derive(Clone)] pub struct ApprovalQueries { approvals: ApprovalStore, @@ -250,6 +284,42 @@ pub(crate) struct GatewayInvokeResponse { pub replayed: bool, } +#[derive(Clone, Debug)] +pub(crate) struct McpIdempotencyRequest { + pub owner_api_token_id: String, + pub key: String, + pub route: String, + pub callable_path: String, + pub arguments: Value, +} + +#[derive(Clone, Debug)] +pub(crate) struct McpIdempotencyBlob { + pub body: Vec, +} + +pub(crate) enum McpIdempotencyClaim { + Fresh(Box), + InProgress, + Replay(McpIdempotencyBlob), + Indeterminate, + Mismatch, +} + +pub(crate) struct McpIdempotencyReservation { + guard: Option, +} + +pub(crate) struct McpIdempotencyExecution { + guard: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct McpIdempotentResponse { + pub result: ToolResult, + pub replayed: bool, +} + #[derive(Debug, Error)] pub(crate) enum GatewayInvokeError { #[error(transparent)] @@ -266,6 +336,8 @@ pub(crate) enum GatewayInvokeError { InProgress, #[error("the idempotent invocation outcome is unknown")] OutcomeUnknown, + #[error("the idempotent invocation wait was canceled")] + Canceled, } impl From for GatewayInvokeError { @@ -384,6 +456,9 @@ async fn settle_abandoned_reservation( .await .map_err(|error| error.to_string()); }; + if approval.record.surface == RequestSurface::Mcp { + return Ok(()); + } let response = match approval_required_response(&approval.record) { Ok(response) => response, Err(_) => { @@ -464,6 +539,54 @@ impl Drop for IdempotencyExecutionGuard { } } +impl McpIdempotencyReservation { + pub(crate) async fn mark_executing( + mut self, + ) -> Result { + let mut guard = self.guard.take().ok_or_else(|| { + GatewayInvokeError::Idempotency( + "idempotency reservation was already consumed".to_owned(), + ) + })?; + guard.store.mark_executing(&guard.record.id).await?; + guard.disarm(); + Ok(McpIdempotencyExecution { + guard: Some(IdempotencyExecutionGuard::new( + guard.record.id.clone(), + guard.store.clone(), + guard.tasks.clone(), + )), + }) + } +} + +impl McpIdempotencyExecution { + pub(crate) async fn mark_indeterminate(mut self) -> Result<(), GatewayInvokeError> { + let mut guard = self.guard.take().ok_or_else(|| { + GatewayInvokeError::Idempotency("idempotency execution was already consumed".to_owned()) + })?; + guard.store.mark_indeterminate(&guard.id).await?; + guard.disarm(); + Ok(()) + } + + pub(crate) async fn complete( + mut self, + response: McpIdempotencyBlob, + ) -> Result<(), GatewayInvokeError> { + let mut guard = self.guard.take().ok_or_else(|| { + GatewayInvokeError::Idempotency("idempotency execution was already consumed".to_owned()) + })?; + let response = mcp_blob_response(response); + guard + .store + .complete(&guard.id, IdempotencyResponseKind::Tool, &response, None) + .await?; + guard.disarm(); + Ok(()) + } +} + struct IdempotencyAskCompletionGuard { id: String, approval_id: String, @@ -473,6 +596,37 @@ struct IdempotencyAskCompletionGuard { armed: bool, } +struct McpApprovalSettlementGuard { + service: ToolCallService, + record: IdempotencyRecord, + actor: ToolActor, + armed: bool, +} + +impl McpApprovalSettlementGuard { + fn new(service: ToolCallService, record: IdempotencyRecord, actor: ToolActor) -> Self { + Self { + service, + record, + actor, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for McpApprovalSettlementGuard { + fn drop(&mut self) { + if self.armed { + self.service + .spawn_mcp_idempotency_settlement(self.record.clone(), self.actor.clone()); + } + } +} + impl IdempotencyAskCompletionGuard { fn new( id: String, @@ -585,8 +739,15 @@ impl ToolCallService { discovery_slots: Arc::new(Semaphore::new(1)), execution_slots: Arc::new(Semaphore::new(APPROVAL_EXECUTION_CONCURRENCY)), in_flight_approvals: Arc::new(Mutex::new(HashSet::new())), + in_flight_mcp_settlements: Arc::new(Mutex::new(HashSet::new())), deferred_execution_cancellations: Arc::new(RwLock::new(HashSet::new())), execution_stopping_flags: Arc::new(Mutex::new(HashMap::new())), + #[cfg(test)] + approval_cancellation_race_hook: Arc::new(Mutex::new(None)), + #[cfg(test)] + approved_execution_hook: Arc::new(Mutex::new(None)), + #[cfg(test)] + cancel_execution_hook: Arc::new(Mutex::new(None)), background_tasks: TaskTracker::default(), } } @@ -595,6 +756,71 @@ impl ToolCallService { &self.approval_queries } + pub(crate) async fn claim_mcp_idempotency( + &self, + request: &McpIdempotencyRequest, + ) -> Result { + if !token_is_active(self.catalog.pool(), &request.owner_api_token_id) + .await + .map_err(ToolCallError::Approval)? + { + return Err(ToolCallError::Approval(ApprovalError::OwnerTokenInactive).into()); + } + let claim = self + .idempotency + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: &request.owner_api_token_id, + }, + key: &request.key, + route: &request.route, + callable_path: &request.callable_path, + arguments: &request.arguments, + }) + .await?; + Ok(match claim { + IdempotencyClaim::Fresh(record) => { + McpIdempotencyClaim::Fresh(Box::new(McpIdempotencyReservation { + guard: Some(IdempotencyReservationGuard::new( + record, + self.idempotency.clone(), + self.approvals.clone(), + self.background_tasks.clone(), + )), + })) + } + IdempotencyClaim::InProgress(_) => McpIdempotencyClaim::InProgress, + IdempotencyClaim::Replay { response, .. } => { + McpIdempotencyClaim::Replay(mcp_response_blob(response)?) + } + IdempotencyClaim::Indeterminate(_) => McpIdempotencyClaim::Indeterminate, + IdempotencyClaim::Mismatch => McpIdempotencyClaim::Mismatch, + }) + } + + pub(crate) async fn wait_mcp_idempotency( + &self, + request: &McpIdempotencyRequest, + cancellation: C, + ) -> Result + where + C: Future + Send, + { + tokio::pin!(cancellation); + let mut delay = Duration::from_millis(10); + loop { + match self.claim_mcp_idempotency(request).await? { + McpIdempotencyClaim::InProgress => {} + terminal => return Ok(terminal), + } + tokio::select! { + () = &mut cancellation => return Err(GatewayInvokeError::Canceled), + () = tokio::time::sleep(delay) => {} + } + delay = (delay * 2).min(Duration::from_millis(250)); + } + } + pub(crate) fn discovery_slots(&self) -> Arc { self.discovery_slots.clone() } @@ -678,6 +904,7 @@ impl ToolCallService { .recover_startup() .await .map_err(idempotency_startup_error)?; + let mut mcp_settlements = Vec::new(); for reservation in idempotency_recovery.reserved { let approval_id = self .idempotency @@ -709,6 +936,27 @@ impl ToolCallService { continue; } }; + if approval.record.surface == RequestSurface::Mcp { + if approval.record.status == ApprovalStatus::Executing { + self.idempotency + .mark_indeterminate(&reservation.id) + .await + .map_err(idempotency_startup_error)?; + continue; + } + let Some(actor_api_token_id) = approval.record.actor_api_token_id.clone() else { + terminalize_idempotency(&self.idempotency, &reservation.id).await; + continue; + }; + mcp_settlements.push(( + reservation, + ToolActor::api_token( + actor_api_token_id, + approval.record.actor_name_snapshot.clone(), + ), + )); + continue; + } let response = match approval_required_response(&approval.record) { Ok(response) => response, Err(_) => { @@ -742,6 +990,9 @@ impl ToolCallService { self.spawn_approved(approval_id.clone()); } } + for (reservation, actor) in mcp_settlements { + self.spawn_mcp_idempotency_settlement(reservation, actor); + } Ok(()) } @@ -922,165 +1173,387 @@ impl ToolCallService { } } - pub(crate) async fn submit_gateway_idempotent( + pub(crate) async fn submit_mcp_idempotent( &self, mut call: ToolCall, + route: &str, key: &str, - ) -> Result { - let started = Instant::now(); + cancellation: C, + ) -> Result + where + C: Future + Send, + { + if call.surface != RequestSurface::Mcp { + return Err(IdempotencyError::InvalidMetadata.into()); + } let actor_api_token_id = call .actor .api_token_id() .ok_or(IdempotencyError::InvalidMetadata)? .to_owned(); - if call.surface != RequestSurface::Gateway { - return Err(IdempotencyError::InvalidMetadata.into()); - } if !token_is_active(self.catalog.pool(), &actor_api_token_id) .await .map_err(ToolCallError::Approval)? { - self.record_attempt( - &call, - None, - None, - RequestOutcome::Denied, - Some("token_revoked"), - None, - started, - ); return Err(ToolCallError::Approval(ApprovalError::OwnerTokenInactive).into()); } let encoded_arguments = serde_json::to_vec(&call.arguments).map_err(|_| ToolCallError::InvalidArguments)?; if encoded_arguments.len() > MAX_ARGUMENT_BYTES { - self.record_attempt( - &call, - None, - None, - RequestOutcome::Failed, - Some("arguments_too_large"), - None, - started, - ); return Err(ToolCallError::ArgumentsTooLarge.into()); } let request_path = idempotency::canonical_callable_path(&call.path)?; - if let Some(existing) = self - .idempotency - .lookup(IdempotencyRequest { - owner: IdempotencyOwner { - owner_api_token_id: &actor_api_token_id, - }, - key, - route: GATEWAY_INVOKE_ROUTE, - callable_path: &request_path, - arguments: &call.arguments, - }) - .await? + tokio::pin!(cancellation); + let request = || IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: &actor_api_token_id, + }, + key, + route, + callable_path: &request_path, + arguments: &call.arguments, + }; + if let Some(claim) = self.idempotency.lookup(request()).await? + && let Some(response) = self + .resolve_mcp_idempotency_claim(claim, &call, &request, cancellation.as_mut(), true) + .await? { - match existing { - IdempotencyClaim::Replay { response, .. } => { - return Ok(GatewayInvokeResponse { - response, - replayed: true, - }); - } - IdempotencyClaim::Mismatch => return Err(GatewayInvokeError::KeyMismatch), - IdempotencyClaim::Indeterminate(_) => { - return Err(GatewayInvokeError::OutcomeUnknown); - } - IdempotencyClaim::InProgress(record) => { - if record.state == idempotency::IdempotencyState::Reserved - && let Some(response) = self - .reconcile_idempotent_approval( - &record, - &actor_api_token_id, - key, - &request_path, - &call.arguments, - ) - .await? + return Ok(response); + } + + let (preflight, record) = loop { + let preflight = self + .catalog + .preflight_invocation(&call.path) + .await + .map_err(ToolCallError::Catalog)?; + if !preflight.arguments_are_valid(&call.arguments) { + return Err(ToolCallError::InvalidArguments.into()); + } + match self.idempotency.claim(request()).await? { + IdempotencyClaim::Fresh(record) => break (preflight, record), + existing => { + drop(preflight); + if let Some(response) = self + .resolve_mcp_idempotency_claim( + existing, + &call, + &request, + cancellation.as_mut(), + true, + ) + .await? { return Ok(response); } - return Err(GatewayInvokeError::InProgress); - } - IdempotencyClaim::Fresh(_) => { - return Err(GatewayInvokeError::Idempotency( - "lookup returned a fresh idempotency claim".to_owned(), - )); } } - } - let preflight = match self.catalog.preflight_invocation(&call.path).await { - Ok(preflight) => preflight, - Err(error) => { - let outcome = if matches!(error, CatalogError::ToolDisabled { .. }) { - RequestOutcome::Denied - } else { - RequestOutcome::Failed - }; - let error = ToolCallError::Catalog(error); - let code = error_code(&error); - self.record_attempt(&call, None, None, outcome, Some(code), None, started); - return Err(error.into()); - } }; - if !preflight.arguments_are_valid(&call.arguments) { - self.record_attempt( - &call, - Some(preflight.lookup().source_id.clone()), - Some(preflight.lookup().tool_id.clone()), - RequestOutcome::Failed, - Some("invalid_tool_arguments"), - None, - started, - ); - return Err(ToolCallError::InvalidArguments.into()); - } let token = preflight.revisions().clone(); let lookup = preflight.lookup().clone(); - let claim = self - .idempotency - .claim(IdempotencyRequest { - owner: IdempotencyOwner { - owner_api_token_id: &actor_api_token_id, - }, - key, - route: GATEWAY_INVOKE_ROUTE, - callable_path: &lookup.callable_path, - arguments: &call.arguments, - }) - .await?; - let record = match claim { - IdempotencyClaim::Fresh(record) => record, - IdempotencyClaim::Replay { response, .. } => { - return Ok(GatewayInvokeResponse { - response, - replayed: true, - }); - } - IdempotencyClaim::Mismatch => return Err(GatewayInvokeError::KeyMismatch), - IdempotencyClaim::Indeterminate(_) => { - return Err(GatewayInvokeError::OutcomeUnknown); - } - IdempotencyClaim::InProgress(record) => { - if record.state == idempotency::IdempotencyState::Reserved - && let Some(response) = self - .reconcile_idempotent_approval( - &record, - &actor_api_token_id, - key, - &lookup.callable_path, - &call.arguments, - ) - .await? - { - return Ok(response); - } - return Err(GatewayInvokeError::InProgress); - } + let mut reservation = IdempotencyReservationGuard::new( + record, + self.idempotency.clone(), + self.approvals.clone(), + self.background_tasks.clone(), + ); + call.execution_id = idempotency_execution_id(&reservation.record.id); + call.call_id = "gateway".to_owned(); + + if lookup.requires_approval { + let input_schema = preflight.input_schema().clone(); + drop(preflight); + let invocation_snapshot = json!({ + "version": 1, + "requestId": call.request_id.clone(), + "actorKind": call.actor.kind(), + "actorId": call.actor.id(), + "actorApiTokenId": call.actor.api_token_id(), + "surface": call.surface, + "executionId": call.execution_id.clone(), + "callId": call.call_id.clone(), + "path": lookup.callable_path.clone(), + "sourceId": lookup.source_id.clone(), + "toolId": lookup.tool_id.clone(), + }); + let pending = self + .approvals + .create(NewApproval { + execution_id: call.execution_id.clone(), + call_id: call.call_id.clone(), + worker_generation: call.worker_generation, + actor: call.actor.clone(), + surface: call.surface, + callable_path_snapshot: lookup.callable_path, + source_display_name_snapshot: Some(lookup.source_display_name), + tool_display_name_snapshot: Some(lookup.tool_display_name), + mode_provenance: lookup.mode_provenance, + revisions: token, + arguments: call.arguments.clone(), + input_schema, + output_schema: None, + invocation_snapshot, + }) + .await + .map_err(ToolCallError::Approval)?; + let approval = ApprovalRequired { + delivery: pending.delivery_pin.then(|| ApprovalDeliveryTicket { + identity: Some(ApprovalDeliveryIdentity { + approval_id: pending.record.id.clone(), + actor: call.actor.clone(), + execution_id: call.execution_id.clone(), + call_id: call.call_id.clone(), + }), + approvals: self.approvals.clone(), + notifications: self.approval_notifications.clone(), + tasks: self.background_tasks.clone(), + }), + record: Box::new(pending.record), + }; + reservation.disarm(); + return self + .finish_mcp_idempotent_approval( + &reservation.record, + &call, + approval, + cancellation.as_mut(), + false, + ) + .await; + } + + drop(preflight); + let lease = match self + .catalog + .revalidate_invocation(&token) + .await + .map_err(ToolCallError::Catalog)? + { + Some(lease) => lease, + None => { + self.idempotency + .release_reserved(&reservation.record.id) + .await?; + reservation.disarm(); + return Err(ToolCallError::Stale.into()); + } + }; + let prepared = match prepare_with_lease(&self.protocols, lease, &call.arguments) { + Ok(prepared) => prepared, + Err(error) => { + self.idempotency + .release_reserved(&reservation.record.id) + .await?; + reservation.disarm(); + return Err(error.into()); + } + }; + self.idempotency + .mark_executing(&reservation.record.id) + .await?; + reservation.disarm(); + let mut execution_guard = IdempotencyExecutionGuard::new( + reservation.record.id.clone(), + self.idempotency.clone(), + self.background_tasks.clone(), + ); + let execution = execute_prepared(&self.protocols, prepared); + tokio::pin!(execution); + let execution_result = tokio::select! { + result = &mut execution => result, + () = &mut cancellation => { + self.idempotency + .mark_indeterminate(&reservation.record.id) + .await?; + execution_guard.disarm(); + return Err(GatewayInvokeError::Canceled); + } + }; + let result = match execution_result { + Ok(result) => result, + Err(error) => { + self.idempotency + .mark_indeterminate(&reservation.record.id) + .await?; + execution_guard.disarm(); + return Err(error.into()); + } + }; + let response = tool_result_response(&result)?; + self.idempotency + .complete( + &reservation.record.id, + IdempotencyResponseKind::Tool, + &response, + None, + ) + .await?; + execution_guard.disarm(); + Ok(McpIdempotentResponse { + result, + replayed: false, + }) + } + + pub(crate) async fn submit_gateway_idempotent( + &self, + mut call: ToolCall, + key: &str, + ) -> Result { + let started = Instant::now(); + let actor_api_token_id = call + .actor + .api_token_id() + .ok_or(IdempotencyError::InvalidMetadata)? + .to_owned(); + if call.surface != RequestSurface::Gateway { + return Err(IdempotencyError::InvalidMetadata.into()); + } + if !token_is_active(self.catalog.pool(), &actor_api_token_id) + .await + .map_err(ToolCallError::Approval)? + { + self.record_attempt( + &call, + None, + None, + RequestOutcome::Denied, + Some("token_revoked"), + None, + started, + ); + return Err(ToolCallError::Approval(ApprovalError::OwnerTokenInactive).into()); + } + let encoded_arguments = + serde_json::to_vec(&call.arguments).map_err(|_| ToolCallError::InvalidArguments)?; + if encoded_arguments.len() > MAX_ARGUMENT_BYTES { + self.record_attempt( + &call, + None, + None, + RequestOutcome::Failed, + Some("arguments_too_large"), + None, + started, + ); + return Err(ToolCallError::ArgumentsTooLarge.into()); + } + let request_path = idempotency::canonical_callable_path(&call.path)?; + if let Some(existing) = self + .idempotency + .lookup(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: &actor_api_token_id, + }, + key, + route: GATEWAY_INVOKE_ROUTE, + callable_path: &request_path, + arguments: &call.arguments, + }) + .await? + { + match existing { + IdempotencyClaim::Replay { response, .. } => { + return Ok(GatewayInvokeResponse { + response, + replayed: true, + }); + } + IdempotencyClaim::Mismatch => return Err(GatewayInvokeError::KeyMismatch), + IdempotencyClaim::Indeterminate(_) => { + return Err(GatewayInvokeError::OutcomeUnknown); + } + IdempotencyClaim::InProgress(record) => { + if record.state == idempotency::IdempotencyState::Reserved + && let Some(response) = self + .reconcile_idempotent_approval( + &record, + &actor_api_token_id, + key, + &request_path, + &call.arguments, + ) + .await? + { + return Ok(response); + } + return Err(GatewayInvokeError::InProgress); + } + IdempotencyClaim::Fresh(_) => { + return Err(GatewayInvokeError::Idempotency( + "lookup returned a fresh idempotency claim".to_owned(), + )); + } + } + } + let preflight = match self.catalog.preflight_invocation(&call.path).await { + Ok(preflight) => preflight, + Err(error) => { + let outcome = if matches!(error, CatalogError::ToolDisabled { .. }) { + RequestOutcome::Denied + } else { + RequestOutcome::Failed + }; + let error = ToolCallError::Catalog(error); + let code = error_code(&error); + self.record_attempt(&call, None, None, outcome, Some(code), None, started); + return Err(error.into()); + } + }; + if !preflight.arguments_are_valid(&call.arguments) { + self.record_attempt( + &call, + Some(preflight.lookup().source_id.clone()), + Some(preflight.lookup().tool_id.clone()), + RequestOutcome::Failed, + Some("invalid_tool_arguments"), + None, + started, + ); + return Err(ToolCallError::InvalidArguments.into()); + } + let token = preflight.revisions().clone(); + let lookup = preflight.lookup().clone(); + let claim = self + .idempotency + .claim(IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: &actor_api_token_id, + }, + key, + route: GATEWAY_INVOKE_ROUTE, + callable_path: &lookup.callable_path, + arguments: &call.arguments, + }) + .await?; + let record = match claim { + IdempotencyClaim::Fresh(record) => record, + IdempotencyClaim::Replay { response, .. } => { + return Ok(GatewayInvokeResponse { + response, + replayed: true, + }); + } + IdempotencyClaim::Mismatch => return Err(GatewayInvokeError::KeyMismatch), + IdempotencyClaim::Indeterminate(_) => { + return Err(GatewayInvokeError::OutcomeUnknown); + } + IdempotencyClaim::InProgress(record) => { + if record.state == idempotency::IdempotencyState::Reserved + && let Some(response) = self + .reconcile_idempotent_approval( + &record, + &actor_api_token_id, + key, + &lookup.callable_path, + &call.arguments, + ) + .await? + { + return Ok(response); + } + return Err(GatewayInvokeError::InProgress); + } }; let mut reservation = IdempotencyReservationGuard::new( record, @@ -1321,6 +1794,274 @@ impl ToolCallService { }) } + async fn resolve_mcp_idempotency_claim<'a, C, F>( + &self, + mut claim: IdempotencyClaim, + call: &ToolCall, + request: &F, + mut cancellation: Pin<&mut C>, + replayed: bool, + ) -> Result, GatewayInvokeError> + where + C: Future + Send, + F: Fn() -> IdempotencyRequest<'a>, + { + let mut delay = Duration::from_millis(10); + loop { + match claim { + IdempotencyClaim::Fresh(_) => return Ok(None), + IdempotencyClaim::Mismatch => return Err(GatewayInvokeError::KeyMismatch), + IdempotencyClaim::Indeterminate(_) => { + return Err(GatewayInvokeError::OutcomeUnknown); + } + IdempotencyClaim::Replay { record, response } => { + if record.response_kind == Some(IdempotencyResponseKind::Tool) { + return Ok(Some(McpIdempotentResponse { + result: tool_result_from_response(&response)?, + replayed, + })); + } + let approval = self.idempotent_approval(&record, call).await?; + return self + .finish_mcp_idempotent_approval( + &record, + call, + approval, + cancellation, + replayed, + ) + .await + .map(Some); + } + IdempotencyClaim::InProgress(record) => { + if record.state == idempotency::IdempotencyState::Reserved + && let Some(CorrelatedApproval::Live(_)) = + self.idempotency.correlated_approval_id(&record).await? + { + let approval = self.idempotent_approval(&record, call).await?; + return self + .finish_mcp_idempotent_approval( + &record, + call, + approval, + cancellation, + replayed, + ) + .await + .map(Some); + } + } + } + tokio::select! { + () = &mut cancellation => return Err(GatewayInvokeError::Canceled), + () = tokio::time::sleep(delay) => {} + } + delay = (delay * 2).min(Duration::from_millis(250)); + let Some(next_claim) = self.idempotency.lookup(request()).await? else { + return Ok(None); + }; + claim = next_claim; + } + } + + async fn idempotent_approval( + &self, + record: &IdempotencyRecord, + call: &ToolCall, + ) -> Result { + let approval_id = match self.idempotency.correlated_approval_id(record).await? { + Some(CorrelatedApproval::Live(approval_id)) => approval_id, + Some(CorrelatedApproval::Retired) | None => { + return Err(GatewayInvokeError::OutcomeUnknown); + } + }; + let detail = self + .approvals + .get_for_actor(&approval_id, &call.actor) + .await + .map_err(ToolCallError::Approval)? + .ok_or(GatewayInvokeError::OutcomeUnknown)?; + if detail.record.execution_id != idempotency_execution_id(&record.id) + || detail.record.call_id != "gateway" + || detail.record.surface != RequestSurface::Mcp + { + return Err(GatewayInvokeError::OutcomeUnknown); + } + Ok(ApprovalRequired { + record: Box::new(detail.record), + delivery: None, + }) + } + + async fn finish_mcp_idempotent_approval( + &self, + record: &IdempotencyRecord, + call: &ToolCall, + approval: ApprovalRequired, + cancellation: Pin<&mut C>, + replayed: bool, + ) -> Result + where + C: Future + Send, + { + let mut settlement_guard = + McpApprovalSettlementGuard::new(self.clone(), record.clone(), call.actor.clone()); + let approval_id = approval.id.clone(); + let approval_execution_id = approval.execution_id.clone(); + let idempotency_execution_id = idempotency_execution_id(&record.id); + let approval_wait = self.wait_for_approval( + approval, + &call.actor, + &idempotency_execution_id, + "gateway", + cancellation, + ); + let idempotency_failure = self.wait_for_mcp_indeterminate(&record.id); + tokio::pin!(approval_wait); + tokio::pin!(idempotency_failure); + let approval_result = tokio::select! { + detail = &mut approval_wait => detail, + result = &mut idempotency_failure => { + result?; + return Err(GatewayInvokeError::OutcomeUnknown); + } + }; + let detail = match approval_result { + Ok(detail) => detail, + Err(ApprovalWaitError::Approval(error)) => { + return Err(GatewayInvokeError::ToolCall(ToolCallError::Approval(error))); + } + Err(ApprovalWaitError::Canceled) => { + self.cancel_mcp_approval_execution(&approval_execution_id) + .await + .map_err(ToolCallError::Approval)?; + self.notify_approval(&approval_id); + if let Some(detail) = self + .approvals + .get_for_actor(&approval_id, &call.actor) + .await + .map_err(ToolCallError::Approval)? + { + if detail.record.status.is_terminal() { + let result = terminal_approval_result(detail.record.status, detail.result)?; + self.complete_mcp_approval_result(record, &result).await?; + settlement_guard.disarm(); + } else if detail.record.status == ApprovalStatus::Executing { + match self.idempotency.mark_indeterminate(&record.id).await { + Ok(_) | Err(IdempotencyError::InvalidTransition("indeterminate")) => {} + Err(IdempotencyError::InvalidTransition("completed")) => {} + Err(error) => return Err(error.into()), + } + settlement_guard.disarm(); + } + } + return Err(GatewayInvokeError::Canceled); + } + }; + let result = terminal_approval_result(detail.record.status, detail.result)?; + self.complete_mcp_approval_result(record, &result).await?; + settlement_guard.disarm(); + Ok(McpIdempotentResponse { result, replayed }) + } + + async fn wait_for_mcp_indeterminate(&self, id: &str) -> Result<(), GatewayInvokeError> { + loop { + match self.idempotency.state(id).await? { + Some(idempotency::IdempotencyState::Indeterminate) => return Ok(()), + Some( + idempotency::IdempotencyState::Reserved + | idempotency::IdempotencyState::Executing + | idempotency::IdempotencyState::Completed, + ) => {} + None => return Err(GatewayInvokeError::OutcomeUnknown), + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + async fn complete_mcp_approval_result( + &self, + record: &IdempotencyRecord, + result: &ToolResult, + ) -> Result<(), GatewayInvokeError> { + if !matches!( + record.state, + idempotency::IdempotencyState::Reserved | idempotency::IdempotencyState::Executing + ) { + return Ok(()); + } + let response = tool_result_response(result)?; + match self + .idempotency + .complete(&record.id, IdempotencyResponseKind::Tool, &response, None) + .await + { + Ok(_) | Err(IdempotencyError::InvalidTransition("completed")) => Ok(()), + Err(IdempotencyError::InvalidTransition("indeterminate")) => { + Err(GatewayInvokeError::OutcomeUnknown) + } + Err(error) => Err(error.into()), + } + } + + fn spawn_mcp_idempotency_settlement(&self, record: IdempotencyRecord, actor: ToolActor) { + if !self + .in_flight_mcp_settlements + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(record.id.clone()) + { + return; + } + let service = self.clone(); + let settlement_id = record.id.clone(); + let spawned = self.background_tasks.spawn(async move { + let call = ToolCall { + request_id: format!("mcp-idempotency-settlement:{}", record.id), + actor, + surface: RequestSurface::Mcp, + execution_id: idempotency_execution_id(&record.id), + call_id: "gateway".to_owned(), + worker_generation: 0, + path: "executor.settlement".to_owned(), + arguments: Value::Null, + }; + let result = async { + let approval = service.idempotent_approval(&record, &call).await?; + let cancellation = std::future::pending::<()>(); + tokio::pin!(cancellation); + service + .finish_mcp_idempotent_approval( + &record, + &call, + approval, + cancellation.as_mut(), + false, + ) + .await + } + .await; + if let Err(error) = result { + tracing::warn!( + idempotency_id = record.id, + error = %error, + "MCP approval idempotency settlement stopped" + ); + } + service + .in_flight_mcp_settlements + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&record.id); + }); + if !spawned { + self.in_flight_mcp_settlements + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&settlement_id); + } + } + async fn reconcile_idempotent_approval( &self, record: &IdempotencyRecord, @@ -1572,6 +2313,20 @@ impl ToolCallService { } else { None }; + #[cfg(test)] + let race_hook = { + self.approval_cancellation_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + }; + #[cfg(test)] + if cancellation_guard.is_some() + && let Some(hook) = race_hook + { + hook.decision_read_acquired.notify_one(); + hook.release_decision.notified().await; + } let result = self .approvals .decide( @@ -1598,6 +2353,36 @@ impl ToolCallService { } pub async fn cancel_execution(&self, execution_id: &str) -> Result { + #[cfg(test)] + let cancel_hook = { + self.cancel_execution_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + }; + #[cfg(test)] + if let Some(hook) = cancel_hook { + if hook + .failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + hook.block_retries.store(true, Ordering::SeqCst); + return Err(ApprovalError::Database(sqlx::Error::Protocol( + "forced cancellation persistence failure".to_owned(), + ))); + } + if hook.block_retries.load(Ordering::SeqCst) { + let release = hook.release_retries.notified(); + tokio::pin!(release); + release.as_mut().enable(); + hook.retry_waiters.fetch_add(1, Ordering::SeqCst); + hook.retry_waiting.notify_one(); + release.await; + } + } let affected = self.approvals.cancel_execution(execution_id).await?; if affected > 0 { self.schedule_approval_log_flush(); @@ -1606,6 +2391,34 @@ impl ToolCallService { Ok(affected) } + async fn cancel_mcp_approval_execution( + &self, + execution_id: &str, + ) -> Result { + #[cfg(test)] + if let Some(hook) = self + .approval_cancellation_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + { + hook.cancellation_write_queued.notify_one(); + } + let mut fence = self.deferred_execution_cancellations.write().await; + let stopping = self.execution_stopping_flag(execution_id); + stopping.store(true, Ordering::Release); + fence.insert(execution_id.to_owned()); + let result = self.cancel_execution(execution_id).await; + if result.is_ok() { + fence.remove(execution_id); + self.clear_execution_stopping_flag(execution_id); + } else { + drop(fence); + self.defer_execution_cancellation(execution_id); + } + result + } + pub(crate) async fn mark_execution_lost(&self, execution_id: &str) { self.execution_stopping_flag(execution_id) .store(true, Ordering::Release); @@ -1641,6 +2454,10 @@ impl ToolCallService { } } } + self.defer_execution_cancellation(execution_id); + } + + fn defer_execution_cancellation(&self, execution_id: &str) { let service = self.clone(); let deferred_execution_id = execution_id.to_owned(); if !self.background_tasks.spawn(async move { @@ -2002,7 +2819,6 @@ impl ToolCallService { self.wait_for_delivery_release(approval_id).await?; return Ok(()); } - drop(cancellation_guard); if !lease.arguments_are_valid(&snapshot.arguments) { let result = error_result(&ToolCallError::InvalidArguments); self.approvals @@ -2019,7 +2835,61 @@ impl ToolCallService { self.wait_for_delivery_release(approval_id).await?; return Ok(()); } + let mut mcp_idempotency_execution = if record.surface == RequestSurface::Mcp { + let id = record + .execution_id + .strip_prefix("gateway-idempotency:") + .filter(|id| !id.is_empty() && record.call_id == "gateway") + .ok_or_else(|| ToolCallError::Adapter { + code: "idempotency_metadata_invalid", + message: "The MCP approval idempotency metadata is invalid.".to_owned(), + })? + .to_owned(); + self.idempotency + .mark_executing(&id) + .await + .map_err(idempotency_tool_call_error)?; + Some(IdempotencyExecutionGuard::new( + id, + self.idempotency.clone(), + self.background_tasks.clone(), + )) + } else { + None + }; + drop(cancellation_guard); + #[cfg(test)] + let execution_hook = { + self.approved_execution_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + }; + #[cfg(test)] + let result = if let Some(hook) = execution_hook { + hook.dispatches.fetch_add(1, Ordering::SeqCst); + hook.started.notify_one(); + hook.release.notified().await; + Ok(ToolResult { + ok: true, + data: Some(json!({ "ok": true })), + error: None, + http: None, + }) + } else { + execute_with_lease(&self.protocols, lease, &snapshot.arguments).await + }; + #[cfg(not(test))] let result = execute_with_lease(&self.protocols, lease, &snapshot.arguments).await; + if result.is_err() + && let Some(execution) = mcp_idempotency_execution.as_mut() + { + self.idempotency + .mark_indeterminate(&execution.id) + .await + .map_err(idempotency_tool_call_error)?; + execution.disarm(); + } let (tool_result, outcome, failure_code) = match result { Ok(result) if result.ok => (result, ExecutionOutcome::Succeeded, None), Ok(result) => ( @@ -2034,7 +2904,8 @@ impl ToolCallService { ), }; let result_json = serde_json::to_value(&tool_result).map_err(ApprovalError::Json)?; - self.approvals + let approval_finish = self + .approvals .finish( approval_id, snapshot.record.revision, @@ -2042,7 +2913,44 @@ impl ToolCallService { &result_json, failure_code, ) - .await?; + .await; + if let Err(error) = approval_finish { + if let Some(execution) = mcp_idempotency_execution.as_mut() + && execution.armed + { + self.idempotency + .mark_indeterminate(&execution.id) + .await + .map_err(idempotency_tool_call_error)?; + execution.disarm(); + } + return Err(error.into()); + } + if let Some(execution) = mcp_idempotency_execution.as_mut() + && execution.armed + { + let response = + tool_result_response(&tool_result).map_err(idempotency_tool_call_error)?; + match self + .idempotency + .complete( + &execution.id, + IdempotencyResponseKind::Tool, + &response, + None, + ) + .await + { + Ok(_) | Err(IdempotencyError::InvalidTransition("completed")) => { + execution.disarm(); + } + Err(error) => { + self.schedule_approval_log_flush(); + self.notify_approval(approval_id); + return Err(idempotency_tool_call_error(error)); + } + } + } self.schedule_approval_log_flush(); self.notify_approval(approval_id); self.wait_for_delivery_release(approval_id).await?; @@ -2224,6 +3132,14 @@ async fn execute_prepared( .await .map_err(|error| match error { ProtocolInvocationError::Outbound(error) => ToolCallError::Outbound(error), + ProtocolInvocationError::Mcp(error) => ToolCallError::Adapter { + code: if error.outcome_unknown() { + "mcp_outcome_unknown" + } else { + "mcp_invocation_failed" + }, + message: "The upstream MCP tool call could not be completed safely.".to_owned(), + }, })?; let result = ToolResult { ok: response.ok, @@ -2252,6 +3168,94 @@ fn tool_result_response(result: &ToolResult) -> Result Result { + if response.status != 200 { + return Err(GatewayInvokeError::Idempotency( + "stored MCP idempotency response has an invalid status".to_owned(), + )); + } + serde_json::from_slice(&response.body) + .map_err(IdempotencyError::Json) + .map_err(GatewayInvokeError::from) +} + +fn mcp_blob_response(response: McpIdempotencyBlob) -> IdempotencyResponse { + IdempotencyResponse { + status: 200, + headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]), + body: response.body, + } +} + +fn mcp_response_blob( + response: IdempotencyResponse, +) -> Result { + if response.status != 200 { + return Err(GatewayInvokeError::Idempotency( + "stored MCP idempotency response has an invalid status".to_owned(), + )); + } + Ok(McpIdempotencyBlob { + body: response.body, + }) +} + +fn terminal_approval_result( + status: ApprovalStatus, + result: Option, +) -> Result { + match status { + ApprovalStatus::Succeeded | ApprovalStatus::Failed => result + .ok_or_else(|| { + GatewayInvokeError::Idempotency("terminal approval result is missing".to_owned()) + }) + .and_then(|result| { + serde_json::from_value(result) + .map_err(IdempotencyError::Json) + .map_err(GatewayInvokeError::from) + }), + ApprovalStatus::Denied => Ok(approval_failure_result( + "approval_denied", + "The tool call was denied.", + )), + ApprovalStatus::Expired => Ok(approval_failure_result( + "approval_expired", + "The tool approval expired.", + )), + ApprovalStatus::Canceled => Ok(approval_failure_result( + "approval_canceled", + "The tool call was canceled.", + )), + ApprovalStatus::Stale => Ok(approval_failure_result( + "approval_stale", + "The tool changed before approval completed.", + )), + ApprovalStatus::Interrupted => Ok(approval_failure_result( + "approval_interrupted", + "The approved tool call was interrupted.", + )), + ApprovalStatus::Pending | ApprovalStatus::Approved | ApprovalStatus::Executing => { + Err(GatewayInvokeError::Idempotency( + "approval wait returned a nonterminal state".to_owned(), + )) + } + } +} + +fn approval_failure_result(code: &str, message: &str) -> ToolResult { + ToolResult { + ok: false, + data: None, + error: Some(PublicToolError { + code: code.to_owned(), + message: message.to_owned(), + }), + http: None, + } +} + fn approval_required_response( approval: &ApprovalRecord, ) -> Result { @@ -2314,6 +3318,13 @@ fn idempotency_startup_error(error: IdempotencyError) -> ApprovalError { } } +fn idempotency_tool_call_error(_error: IdempotencyError) -> ToolCallError { + ToolCallError::Adapter { + code: "idempotency_failed", + message: "The idempotent tool call could not be completed safely.".to_owned(), + } +} + fn error_code(error: &ToolCallError) -> &'static str { match error { ToolCallError::Approval(_) => "approval_error", @@ -2362,15 +3373,114 @@ fn error_result(error: &ToolCallError) -> ToolResult { #[cfg(test)] mod idempotency_guard_tests { - use std::{future::pending, time::Duration}; + use std::{collections::BTreeMap, future::pending, time::Duration}; use serde_json::json; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; use super::*; + use crate::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, + }; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + async fn app_with_mcp_ask_tool_at(server_url: &str) -> (tempfile::TempDir, ExecutorApp) { + let directory = tempfile::tempdir().expect("temporary directory"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "INSERT INTO api_tokens \ + (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('mcp-owner', 'MCP owner', x'010203', 'exr_test', 'test', 1)", + ) + .execute(app.pool()) + .await + .expect("owner token"); + sqlx::query( + "INSERT INTO admins (id, username, password_hash, created_at) \ + VALUES (1, 'admin', 'unused', 1)", + ) + .execute(app.pool()) + .await + .expect("admin"); + app.catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "mcp-approval".to_owned(), + display_name: "MCP approval source".to_owned(), + description: None, + configuration: json!({ + "spec": { "type": "inline" }, + "allowPrivateNetwork": true + }) + .as_object() + .expect("source configuration should be an object") + .clone(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { "schemes": {} } + }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "write".to_owned(), + preferred_name: "write".to_owned(), + display_name: "Write".to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { "value": { "type": "string" } } + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Ask, + }], + }, + vec![StagedToolBinding { + stable_key: "write".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "POST".to_owned(), + path_template: "/write".to_owned(), + server_url: server_url.to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }], + AuditContext::system(Some("mcp-approval-test")), + ) + .await + .expect("Ask tool should import"); + (directory, app) + } + #[tokio::test] async fn canceled_pre_execution_task_releases_its_reservation() { let directory = tempfile::tempdir().expect("temporary directory"); @@ -2596,4 +3706,717 @@ mod idempotency_guard_tests { .expect("dropped Ask completion is durably finished"); tasks.shutdown().await; } + + #[tokio::test] + async fn explicit_mcp_ask_cancellation_blocks_later_approval_and_replays_canceled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test upstream should bind"); + let server_url = format!( + "http://{}", + listener + .local_addr() + .expect("listener should have an address") + ); + let upstream_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let server_count = upstream_count.clone(); + let (stop_server, mut server_stopped) = oneshot::channel::<()>(); + let upstream = tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = &mut server_stopped => break, + accepted = listener.accept() => { + let (mut connection, _) = accepted.expect("upstream should accept"); + server_count.fetch_add(1, Ordering::SeqCst); + let mut request = vec![0_u8; 4096]; + let _ = connection.read(&mut request).await; + let _ = connection.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}", + ).await; + } + } + } + }); + let (_directory, app) = app_with_mcp_ask_tool_at(&server_url).await; + let race_hook = Arc::new(ApprovalCancellationRaceHook::default()); + *app.tool_calls() + .approval_cancellation_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(race_hook.clone()); + let service = app.tool_calls().clone(); + let call = ToolCall { + request_id: "mcp-ask-request".to_owned(), + actor: ToolActor::api_token("mcp-owner", Some("MCP owner".to_owned())), + surface: RequestSurface::Mcp, + execution_id: "downstream-session".to_owned(), + call_id: "rpc-42".to_owned(), + worker_generation: 0, + path: "mcp_approval.write".to_owned(), + arguments: json!({ "value": "write once" }), + }; + let retry_call = call.clone(); + let (cancel, canceled) = oneshot::channel::<()>(); + let submission = tokio::spawn(async move { + service + .submit_mcp_idempotent(call, "POST /mcp tools/call", "session:rpc-42", async { + let _ = canceled.await; + }) + .await + }); + + let approval_id = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(approval_id) = sqlx::query_scalar::<_, String>( + "SELECT approval_id FROM approval_correlations \ + WHERE execution_id LIKE 'gateway-idempotency:%' AND call_id = 'gateway'", + ) + .fetch_optional(app.pool()) + .await + .expect("approval correlation should read") + { + break approval_id; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("approval correlation should appear"); + let pending_approval = app + .tool_calls() + .approvals() + .get_admin(&approval_id) + .await + .expect("approval should read before cancellation") + .expect("approval should exist before cancellation"); + let decision_service = app.tool_calls().clone(); + let decision_approval_id = approval_id.clone(); + let decision = tokio::spawn(async move { + decision_service + .decide( + &decision_approval_id, + "approve-racing-cancel", + pending_approval.record.revision, + ApprovalDecision::Approve, + 1, + ) + .await + }); + tokio::time::timeout( + Duration::from_secs(2), + race_hook.decision_read_acquired.notified(), + ) + .await + .expect("approval should hold the cancellation read fence"); + cancel.send(()).expect("wait cancellation should send"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let delivery_pin = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM approval_delivery_pins WHERE approval_id = ?)", + ) + .bind(&approval_id) + .fetch_one(app.pool()) + .await + .expect("approval delivery pin should read"); + if delivery_pin == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("explicit cancellation should release delivery before fencing execution"); + tokio::time::timeout( + Duration::from_secs(2), + race_hook.cancellation_write_queued.notified(), + ) + .await + .expect("cancellation should queue behind the approval read fence"); + race_hook.release_decision.notify_one(); + let canceled = submission + .await + .expect("submission task should not panic") + .expect_err("submission wait should be canceled"); + assert!(matches!(canceled, GatewayInvokeError::Canceled)); + + let _ = decision.await.expect("decision task should not panic"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let after_later_approval = app + .tool_calls() + .approvals() + .get_admin(&approval_id) + .await + .expect("approval should reread") + .expect("approval should remain stored"); + if after_later_approval.record.status == ApprovalStatus::Canceled { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cancellation should win before approved execution starts"); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency \ + WHERE owner_api_token_id = 'mcp-owner'", + ) + .fetch_one(app.pool()) + .await + .expect("idempotency state should read"); + if state == "completed" { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("settlement worker should complete the idempotency row"); + + let replay = app + .tool_calls() + .submit_mcp_idempotent( + retry_call, + "POST /mcp tools/call", + "session:rpc-42", + pending(), + ) + .await + .expect("exact retry should replay"); + assert!(replay.replayed); + assert!(!replay.result.ok); + assert_eq!( + replay + .result + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("approval_canceled") + ); + let (rows, state, response_kind) = sqlx::query_as::<_, (i64, String, String)>( + "SELECT COUNT(*), MIN(state), MIN(response_kind) \ + FROM gateway_invocation_idempotency WHERE owner_api_token_id = 'mcp-owner'", + ) + .fetch_one(app.pool()) + .await + .expect("idempotency row should read"); + assert_eq!(rows, 1); + assert_eq!(state, "completed"); + assert_eq!(response_kind, "tool"); + stop_server.send(()).expect("upstream shutdown should send"); + upstream.await.expect("upstream task should not panic"); + assert_eq!(upstream_count.load(Ordering::SeqCst), 0); + app.shutdown().await; + } + + #[tokio::test] + async fn cancellation_storage_failure_keeps_execution_fenced_until_retry_succeeds() { + let (_directory, app) = app_with_mcp_ask_tool_at("http://127.0.0.1:9").await; + let execution_hook = Arc::new(ApprovedExecutionHook::default()); + *app.tool_calls() + .approved_execution_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(execution_hook.clone()); + let cancel_hook = Arc::new(CancelExecutionHook::default()); + cancel_hook.failures.store(1, Ordering::SeqCst); + *app.tool_calls() + .cancel_execution_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(cancel_hook.clone()); + let call = ToolCall { + request_id: "mcp-cancel-storage-failure".to_owned(), + actor: ToolActor::api_token("mcp-owner", Some("MCP owner".to_owned())), + surface: RequestSurface::Mcp, + execution_id: "downstream-session".to_owned(), + call_id: "rpc-cancel-storage-failure".to_owned(), + worker_generation: 0, + path: "mcp_approval.write".to_owned(), + arguments: json!({ "value": "write once" }), + }; + let retry_call = call.clone(); + let service = app.tool_calls().clone(); + let (cancel, canceled) = oneshot::channel::<()>(); + let submission = tokio::spawn(async move { + service + .submit_mcp_idempotent( + call, + "POST /mcp tools/call", + "session:cancel-storage-failure", + async { + let _ = canceled.await; + }, + ) + .await + }); + let approval = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(detail) = app + .tool_calls() + .approvals() + .list_admin(crate::approval::ApprovalListQuery { + before_sequence: None, + limit: 1, + status: None, + }) + .await + .expect("approvals should list") + .items + .into_iter() + .next() + { + break detail; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("approval should appear"); + cancel.send(()).expect("explicit cancellation should send"); + let first_result = tokio::time::timeout(Duration::from_secs(2), submission) + .await + .expect("storage failure should return to the original request") + .expect("submission task should not panic") + .expect_err("forced cancellation storage failure should surface"); + assert!(matches!( + first_result, + GatewayInvokeError::ToolCall(ToolCallError::Approval(ApprovalError::Database(_))) + )); + tokio::time::timeout(Duration::from_secs(2), async { + while cancel_hook.retry_waiters.load(Ordering::SeqCst) < 1 { + cancel_hook.retry_waiting.notified().await; + } + }) + .await + .expect("deferred cancellation retry should start"); + + let decision_service = app.tool_calls().clone(); + let approval_id = approval.id.clone(); + let decision = tokio::spawn(async move { + decision_service + .decide( + &approval_id, + "approve-while-cancel-retries", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + while cancel_hook.retry_waiters.load(Ordering::SeqCst) < 2 { + cancel_hook.retry_waiting.notified().await; + } + }) + .await + .expect("approval should remain blocked behind cancellation retry"); + assert!(!decision.is_finished()); + assert_eq!(execution_hook.dispatches.load(Ordering::SeqCst), 0); + cancel_hook.block_retries.store(false, Ordering::SeqCst); + cancel_hook.release_retries.notify_waiters(); + let decision_result = decision.await.expect("decision task should not panic"); + assert!(decision_result.is_err()); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let detail = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read") + .expect("approval should remain stored"); + if detail.record.status == ApprovalStatus::Canceled { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cancellation retry should persist the canceled state"); + let replay = app + .tool_calls() + .submit_mcp_idempotent( + retry_call, + "POST /mcp tools/call", + "session:cancel-storage-failure", + pending(), + ) + .await + .expect("retry should replay canceled result"); + assert!(replay.replayed); + assert_eq!( + replay + .result + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("approval_canceled") + ); + assert_eq!(execution_hook.dispatches.load(Ordering::SeqCst), 0); + app.shutdown().await; + } + + #[tokio::test] + async fn approval_finish_failure_makes_current_and_replay_outcomes_unknown() { + let (_directory, app) = app_with_mcp_ask_tool_at("http://127.0.0.1:9").await; + let execution_hook = Arc::new(ApprovedExecutionHook::default()); + *app.tool_calls() + .approved_execution_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(execution_hook.clone()); + sqlx::query( + "CREATE TRIGGER fail_mcp_approval_finish \ + BEFORE UPDATE OF status ON approvals \ + WHEN OLD.status = 'executing' \ + AND NEW.status IN ('succeeded', 'failed', 'interrupted') \ + BEGIN SELECT RAISE(ABORT, 'forced approval finish failure'); END", + ) + .execute(app.pool()) + .await + .expect("finish failure trigger should install"); + + let call = ToolCall { + request_id: "mcp-finish-failure".to_owned(), + actor: ToolActor::api_token("mcp-owner", Some("MCP owner".to_owned())), + surface: RequestSurface::Mcp, + execution_id: "downstream-session".to_owned(), + call_id: "rpc-finish-failure".to_owned(), + worker_generation: 0, + path: "mcp_approval.write".to_owned(), + arguments: json!({ "value": "write once" }), + }; + let retry_call = call.clone(); + let service = app.tool_calls().clone(); + let submission = tokio::spawn(async move { + service + .submit_mcp_idempotent( + call, + "POST /mcp tools/call", + "session:finish-failure", + pending(), + ) + .await + }); + let approval = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(detail) = app + .tool_calls() + .approvals() + .list_admin(crate::approval::ApprovalListQuery { + before_sequence: None, + limit: 1, + status: None, + }) + .await + .expect("approvals should list") + .items + .into_iter() + .next() + { + break detail; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("approval should appear"); + + app.tool_calls() + .decide( + &approval.id, + "approve-for-finish-failure", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval decision should persist"); + tokio::time::timeout(Duration::from_secs(2), execution_hook.started.notified()) + .await + .expect("approved execution should reach dispatch"); + execution_hook.release.notify_one(); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency \ + WHERE owner_api_token_id = 'mcp-owner'", + ) + .fetch_one(app.pool()) + .await + .expect("idempotency state should read"); + if state == "indeterminate" { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("finish failure should first make the invocation indeterminate"); + let unfinished_approval = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read after finish failure") + .expect("approval should remain stored after finish failure"); + assert_eq!(unfinished_approval.record.status, ApprovalStatus::Executing); + + let current = tokio::time::timeout(Duration::from_secs(2), submission) + .await + .expect("current request should finish") + .expect("submission task should not panic") + .expect_err("failed terminal persistence should be uncertain"); + assert!(matches!(current, GatewayInvokeError::OutcomeUnknown)); + let replay = app + .tool_calls() + .submit_mcp_idempotent( + retry_call, + "POST /mcp tools/call", + "session:finish-failure", + pending(), + ) + .await + .expect_err("retry should preserve the uncertain outcome"); + assert!(matches!(replay, GatewayInvokeError::OutcomeUnknown)); + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency \ + WHERE owner_api_token_id = 'mcp-owner'", + ) + .fetch_one(app.pool()) + .await + .expect("idempotency state should read"); + assert_eq!(state, "indeterminate"); + app.shutdown().await; + } + + #[tokio::test] + async fn explicit_cancellation_after_execution_starts_returns_and_stays_indeterminate() { + let (_directory, app) = app_with_mcp_ask_tool_at("http://127.0.0.1:9").await; + let execution_hook = Arc::new(ApprovedExecutionHook::default()); + *app.tool_calls() + .approved_execution_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(execution_hook.clone()); + let call = ToolCall { + request_id: "mcp-cancel-executing".to_owned(), + actor: ToolActor::api_token("mcp-owner", Some("MCP owner".to_owned())), + surface: RequestSurface::Mcp, + execution_id: "downstream-session".to_owned(), + call_id: "rpc-cancel-executing".to_owned(), + worker_generation: 0, + path: "mcp_approval.write".to_owned(), + arguments: json!({ "value": "write once" }), + }; + let retry_call = call.clone(); + let service = app.tool_calls().clone(); + let (cancel, canceled) = oneshot::channel::<()>(); + let submission = tokio::spawn(async move { + service + .submit_mcp_idempotent( + call, + "POST /mcp tools/call", + "session:cancel-executing", + async { + let _ = canceled.await; + }, + ) + .await + }); + let approval = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(detail) = app + .tool_calls() + .approvals() + .list_admin(crate::approval::ApprovalListQuery { + before_sequence: None, + limit: 1, + status: None, + }) + .await + .expect("approvals should list") + .items + .into_iter() + .next() + { + break detail; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("approval should appear"); + app.tool_calls() + .decide( + &approval.id, + "approve-before-cancel", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should persist"); + tokio::time::timeout(Duration::from_secs(2), execution_hook.started.notified()) + .await + .expect("execution should reach dispatch"); + let executing = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read while upstream is blocked") + .expect("approval should remain stored"); + assert_eq!(executing.record.status, ApprovalStatus::Executing); + + cancel.send(()).expect("explicit cancellation should send"); + let canceled = tokio::time::timeout(Duration::from_secs(2), submission) + .await + .expect("cancellation should not wait for upstream") + .expect("submission task should not panic") + .expect_err("executing request cancellation should return canceled"); + assert!(matches!(canceled, GatewayInvokeError::Canceled)); + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency \ + WHERE owner_api_token_id = 'mcp-owner'", + ) + .fetch_one(app.pool()) + .await + .expect("idempotency state should read"); + assert_eq!(state, "indeterminate"); + + execution_hook.release.notify_one(); + let retry = app + .tool_calls() + .submit_mcp_idempotent( + retry_call, + "POST /mcp tools/call", + "session:cancel-executing", + pending(), + ) + .await + .expect_err("completed upstream work must not become a deterministic replay"); + assert!(matches!(retry, GatewayInvokeError::OutcomeUnknown)); + let final_state = sqlx::query_scalar::<_, String>( + "SELECT state FROM gateway_invocation_idempotency \ + WHERE owner_api_token_id = 'mcp-owner'", + ) + .fetch_one(app.pool()) + .await + .expect("final idempotency state should read"); + assert_eq!(final_state, "indeterminate"); + app.shutdown().await; + } + + #[tokio::test] + async fn generic_mcp_boundary_replays_exact_blobs_and_terminalizes_drops() { + let directory = tempfile::tempdir().expect("temporary directory"); + let pool = SqlitePoolOptions::new() + .max_connections(4) + .connect_with( + SqliteConnectOptions::new() + .filename(directory.path().join("mcp-boundary.db")) + .create_if_missing(true) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(2)), + ) + .await + .expect("database pool"); + MIGRATOR.run(&pool).await.expect("migrations"); + sqlx::query( + "INSERT INTO api_tokens (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('mcp-owner', 'MCP owner', zeroblob(32), 'tok_', 'tail', 1)", + ) + .execute(&pool) + .await + .expect("owner token"); + let keyring = Keyring::from_master_key([11; 32]).expect("keyring"); + let store = GatewayIdempotencyStore::new(pool.clone(), keyring.clone()); + let approvals = ApprovalStore::system(pool, keyring); + let tasks = TaskTracker::default(); + let arguments = json!({ "code": "return 1" }); + let request = || IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: "mcp-owner", + }, + key: "session:request-1", + route: "mcp tools/call", + callable_path: "executor.execute", + arguments: &arguments, + }; + let IdempotencyClaim::Fresh(record) = + store.claim(request()).await.expect("fresh reservation") + else { + panic!("fresh reservation expected"); + }; + let reservation = McpIdempotencyReservation { + guard: Some(IdempotencyReservationGuard::new( + record, + store.clone(), + approvals.clone(), + tasks.clone(), + )), + }; + reservation + .mark_executing() + .await + .expect("execution boundary") + .complete(McpIdempotencyBlob { + body: br#"{"result":1}"#.to_vec(), + }) + .await + .expect("durable completion"); + let IdempotencyClaim::Replay { response, .. } = + store.claim(request()).await.expect("exact replay") + else { + panic!("completed request must replay"); + }; + assert_eq!(response.body, br#"{"result":1}"#); + + let dropped_arguments = json!({ "code": "return 2" }); + let dropped_request = || IdempotencyRequest { + owner: IdempotencyOwner { + owner_api_token_id: "mcp-owner", + }, + key: "session:request-2", + route: "mcp tools/call", + callable_path: "executor.execute", + arguments: &dropped_arguments, + }; + let IdempotencyClaim::Fresh(record) = store + .claim(dropped_request()) + .await + .expect("second reservation") + else { + panic!("fresh second reservation expected"); + }; + let execution = McpIdempotencyReservation { + guard: Some(IdempotencyReservationGuard::new( + record, + store.clone(), + approvals, + tasks.clone(), + )), + } + .mark_executing() + .await + .expect("second execution boundary"); + drop(execution); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if matches!( + store.claim(dropped_request()).await.expect("dropped claim"), + IdempotencyClaim::Indeterminate(_) + ) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped execution becomes indeterminate"); + tasks.shutdown().await; + } } diff --git a/src/lib.rs b/src/lib.rs index e1d32090f..2f386ae8e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,10 +16,12 @@ pub mod actor; mod api; pub mod approval; pub mod catalog; +pub mod cli; pub mod crypto; mod database; pub mod execution; pub use approval::invocation; +pub(crate) mod mcp; pub mod openapi; pub mod outbound; pub(crate) mod protocols; @@ -41,6 +43,7 @@ pub struct AppConfig { pub(crate) session_ttl_seconds: i64, pub(crate) trusted_proxies: Arc<[IpNet]>, pub(crate) runtime_executable: Option, + pub(crate) mcp_stdio_templates_file: Option, } impl AppConfig { @@ -52,6 +55,7 @@ impl AppConfig { session_ttl_seconds: 8 * 60 * 60, trusted_proxies: Arc::from([]), runtime_executable: None, + mcp_stdio_templates_file: None, } } @@ -76,6 +80,11 @@ impl AppConfig { self } + pub fn with_mcp_stdio_templates_file(mut self, path: Option) -> Self { + self.mcp_stdio_templates_file = path; + self + } + pub fn with_origin(mut self, origin: &str) -> Result { self.origin = parse_public_origin(origin)?; Ok(self) @@ -134,15 +143,40 @@ pub struct ExecutorApp { catalog: catalog::CatalogStore, tool_calls: invocation::ToolCallService, execution: execution::ExecutionService, + mcp_connections: Arc, + #[cfg(test)] + mcp: mcp::downstream::McpState, + api_tasks: tasks::TaskTracker, +} + +#[derive(Clone)] +pub struct ExecutorShutdown { + mcp_connections: Arc, + execution: execution::ExecutionService, api_tasks: tasks::TaskTracker, + tool_calls: invocation::ToolCallService, +} + +impl ExecutorShutdown { + pub fn begin(&self) { + self.mcp_connections.begin_shutdown(); + self.execution.cancel_all(); + self.api_tasks.abort_all(); + self.tool_calls.abort_background_tasks(); + } } impl ExecutorApp { pub async fn open(config: AppConfig) -> Result { + let stdio_templates = match config.mcp_stdio_templates_file.as_deref() { + Some(path) => mcp::upstream::stdio::StdioTemplateRegistry::load(path)?, + None => mcp::upstream::stdio::StdioTemplateRegistry::default(), + }; + let mcp_connections = Arc::new(mcp::manager::McpConnectionManager::new(stdio_templates)); let opened = database::Database::open(&config).await?; let pool = opened.database.pool.clone(); let catalog = catalog::CatalogStore::new(pool.clone(), opened.database.keyring.clone()); - let sources = protocols::SourceService::new(catalog.clone()); + let sources = protocols::SourceService::new(catalog.clone(), mcp_connections.clone()); let protocol_registry = sources.registry().clone(); let request_logs = request_logs::RequestLogSink::new(opened.database.clone(), catalog.clone()); @@ -161,10 +195,24 @@ impl ExecutorApp { })); let execution = execution::ExecutionService::new(runtime, tool_calls.clone()); let dummy_password_hash = crypto::hash_password("executor-dummy-login-password")?; + let mcp = mcp::downstream::McpState::new( + opened.database.clone(), + catalog.clone(), + tool_calls.clone(), + execution.clone(), + api_tasks.clone(), + config.public_origin(), + ); + if let Err(error) = sources.restore_mcp_watchers().await { + tracing::warn!( + code = error.code, + "existing MCP source watchers could not be fully restored" + ); + } let router = api::router( opened.database, catalog.clone(), - api::ApiServices::new(sources, tool_calls.clone(), execution.clone()), + api::ApiServices::new(sources, tool_calls.clone(), execution.clone(), mcp.clone()), request_logs, api_tasks.clone(), &config, @@ -177,6 +225,9 @@ impl ExecutorApp { catalog, tool_calls, execution, + mcp_connections, + #[cfg(test)] + mcp, api_tasks, }) } @@ -205,19 +256,37 @@ impl ExecutorApp { &self.execution } + #[cfg(test)] + pub(crate) fn mcp_state(&self) -> &mcp::downstream::McpState { + &self.mcp + } + + pub fn begin_shutdown(&self) { + self.shutdown_handle().begin(); + } + + pub fn shutdown_handle(&self) -> ExecutorShutdown { + ExecutorShutdown { + mcp_connections: self.mcp_connections.clone(), + execution: self.execution.clone(), + api_tasks: self.api_tasks.clone(), + tool_calls: self.tool_calls.clone(), + } + } + pub async fn shutdown(self) { + self.begin_shutdown(); self.execution.shutdown().await; self.api_tasks.shutdown().await; self.tool_calls.shutdown().await; + self.mcp_connections.shutdown().await; self.pool.close().await; } } impl Drop for ExecutorApp { fn drop(&mut self) { - self.execution.cancel_all(); - self.api_tasks.abort_all(); - self.tool_calls.abort_background_tasks(); + self.begin_shutdown(); } } diff --git a/src/main.rs b/src/main.rs index 5bb266930..18e527f27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand}; -use executor::{AppConfig, DEFAULT_BIND_ADDRESS, ExecutorApp}; +use executor::{AppConfig, DEFAULT_BIND_ADDRESS, ExecutorApp, cli}; use ipnet::IpNet; use tokio::net::TcpListener; use tracing::info; @@ -15,6 +15,28 @@ use tracing_subscriber::EnvFilter; #[derive(Parser)] #[command(name = "executor", about = "The local Executor gateway")] struct Cli { + #[arg( + long, + global = true, + env = "EXECUTOR_BASE_URL", + default_value = cli::DEFAULT_BASE_URL + )] + base_url: String, + #[arg( + long, + global = true, + env = "EXECUTOR_API_TOKEN", + hide_env_values = true + )] + api_token: Option, + #[arg(long, global = true)] + json: bool, + #[arg( + long, + global = true, + help = "Allow API tokens over plaintext HTTP to a non-loopback server" + )] + allow_insecure_http: bool, #[command(subcommand)] command: Command, } @@ -22,6 +44,10 @@ struct Cli { #[derive(Subcommand)] enum Command { Server(ServerArgs), + Call(cli::CallArgs), + Tools(cli::ToolsArgs), + Open, + Mcp, #[command(hide = true)] SandboxWorker(SandboxWorkerArgs), } @@ -42,6 +68,8 @@ struct ServerArgs { data_dir: Option, #[arg(long, env = "EXECUTOR_MASTER_KEY_FILE")] master_key_file: Option, + #[arg(long, env = "EXECUTOR_MCP_STDIO_TEMPLATES_FILE")] + mcp_stdio_templates: Option, #[arg(long, env = "EXECUTOR_PUBLIC_ORIGIN")] public_origin: Option, #[arg( @@ -64,8 +92,19 @@ async fn main() -> Result<()> { ) .init(); - match Cli::parse().command { + let arguments = Cli::parse(); + let connection = cli::ConnectionOptions { + base_url: arguments.base_url, + api_token: arguments.api_token, + json: arguments.json, + allow_insecure_http: arguments.allow_insecure_http, + }; + match arguments.command { Command::Server(args) => run_server(args).await, + Command::Call(args) => cli::call(connection, args).await, + Command::Tools(args) => cli::tools(connection, args).await, + Command::Open => cli::open(&connection), + Command::Mcp => cli::mcp(connection).await, Command::SandboxWorker(args) => { let ipc = take_worker_socket(args.ipc_fd)?; executor::runtime::worker_main(ipc, args.generation) @@ -111,6 +150,7 @@ async fn run_server(args: ServerArgs) -> Result<()> { }; config = config .with_master_key_file(args.master_key_file) + .with_mcp_stdio_templates_file(args.mcp_stdio_templates) .with_trusted_proxies(args.trusted_proxies); let listener = TcpListener::bind(args.bind) .await @@ -133,12 +173,16 @@ async fn run_server(args: ServerArgs) -> Result<()> { } info!(address = %bound_address, "Executor is listening"); + let router = app.router(); + let shutdown = app.shutdown_handle(); let server_result = axum::serve( listener, - app.router() - .into_make_service_with_connect_info::(), + router.into_make_service_with_connect_info::(), ) - .with_graceful_shutdown(shutdown_signal()) + .with_graceful_shutdown(async move { + shutdown_signal().await; + shutdown.begin(); + }) .await .context("Executor server stopped unexpectedly"); app.shutdown().await; @@ -170,3 +214,38 @@ async fn shutdown_signal() { async fn shutdown_signal() { let _ = tokio::signal::ctrl_c().await; } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn parses_retained_client_commands_and_global_connection_flags() { + let cli = Cli::try_parse_from([ + "executor", + "--base-url", + "http://localhost:9000", + "--api-token", + "secret", + "--json", + "call", + "github", + "issues_create", + r#"{"title":"Bug"}"#, + ]) + .expect("call command"); + assert_eq!(cli.base_url, "http://localhost:9000"); + assert_eq!(cli.api_token.as_deref(), Some("secret")); + assert!(cli.json); + let Command::Call(arguments) = cli.command else { + panic!("expected call command"); + }; + assert_eq!(arguments.values.len(), 3); + + assert!(Cli::try_parse_from(["executor", "tools", "sources"]).is_ok()); + assert!(Cli::try_parse_from(["executor", "mcp"]).is_ok()); + assert!(Cli::try_parse_from(["executor", "open"]).is_ok()); + } +} diff --git a/src/mcp/discovery/mod.rs b/src/mcp/discovery/mod.rs new file mode 100644 index 000000000..fefd86566 --- /dev/null +++ b/src/mcp/discovery/mod.rs @@ -0,0 +1,531 @@ +use std::{ + collections::{BTreeMap, HashSet}, + future::Future, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use thiserror::Error; + +use crate::catalog::{ + ArtifactKind, CatalogSnapshot, InitialCatalogSnapshot, McpToolBindingV1, StagedArtifact, + StagedTool, StagedToolBinding, ToolBinding, ToolMode, +}; + +const CAPABILITIES_ARTIFACT_KEY: &str = "mcp-server"; + +#[derive(Clone, Copy, Debug)] +pub struct DiscoveryLimits { + pub max_pages: usize, + pub max_tools: usize, + pub max_cursor_bytes: usize, + pub max_tool_name_characters: usize, + pub max_title_characters: usize, + pub max_description_characters: usize, + pub max_schema_bytes: usize, + pub max_tool_bytes: usize, + pub max_total_tool_bytes: usize, + pub max_capabilities_bytes: usize, +} + +impl Default for DiscoveryLimits { + fn default() -> Self { + Self { + max_pages: 1_000, + max_tools: 100_000, + max_cursor_bytes: 8 * 1024, + max_tool_name_characters: 128, + max_title_characters: 300, + max_description_characters: 4_000, + max_schema_bytes: 2 * 1024 * 1024, + max_tool_bytes: 5 * 1024 * 1024, + max_total_tool_bytes: 32 * 1024 * 1024, + max_capabilities_bytes: 8 * 1024 * 1024, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveryBasis { + pub expected_source_revision: i64, + pub expected_credential_revision: Option, + pub protocol_version: String, + pub server_name: String, + pub server_version: String, + pub server_title: Option, + pub instructions: Option, + pub capabilities: Value, + pub tools_list_changed: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolAnnotations { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_only_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destructive_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotent_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_hint: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveredMcpTool { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub input_schema: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotations: Option, + #[serde(default, skip_serializing_if = "Map::is_empty", rename = "_meta")] + pub meta: Map, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolPage { + pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[async_trait] +pub trait ToolPageFetcher: Send { + type Error: std::error::Error + Send + Sync + 'static; + + async fn fetch_tools_page(&mut self, cursor: Option<&str>) -> Result; +} + +#[derive(Clone, Debug)] +pub struct DiscoveryPlan { + pub basis: DiscoveryBasis, + pub artifacts: Vec, + pub tools: Vec, + pub bindings: Vec, +} + +impl DiscoveryPlan { + pub fn catalog_snapshot(&self) -> CatalogSnapshot { + CatalogSnapshot { + expected_source_revision: self.basis.expected_source_revision, + expected_credential_revision: self.basis.expected_credential_revision, + artifacts: self.artifacts.clone(), + tools: self.tools.clone(), + } + } + + pub fn initial_catalog_snapshot(&self) -> InitialCatalogSnapshot { + InitialCatalogSnapshot { + artifacts: self.artifacts.clone(), + tools: self.tools.clone(), + } + } +} + +#[derive(Debug, Error)] +pub enum DiscoveryError { + #[error("MCP tools/list failed: {0}")] + Fetch(#[source] Box), + #[error("MCP discovery exceeded the {maximum} page limit")] + TooManyPages { maximum: usize }, + #[error("MCP discovery exceeded the {maximum} tool limit")] + TooManyTools { maximum: usize }, + #[error("MCP tools/list returned an invalid cursor")] + InvalidCursor, + #[error("MCP tools/list returned a cursor cycle")] + CursorCycle, + #[error("MCP tools/list returned duplicate tool name {name:?}")] + DuplicateTool { name: String }, + #[error("MCP tool {tool:?} is invalid: {message}")] + InvalidTool { tool: String, message: &'static str }, + #[error("MCP server discovery metadata is too large")] + CapabilitiesTooLarge, + #[error("MCP server capabilities must be a JSON object")] + InvalidCapabilities, + #[error("MCP tools/list returned too much tool metadata")] + ToolMetadataTooLarge, +} + +pub async fn discover( + fetcher: &mut F, + basis: DiscoveryBasis, +) -> Result { + discover_with_limits(fetcher, basis, DiscoveryLimits::default()).await +} + +pub async fn discover_with_limits( + fetcher: &mut F, + basis: DiscoveryBasis, + limits: DiscoveryLimits, +) -> Result { + let artifact = capabilities_artifact(&basis, limits)?; + let mut cursor = None; + let mut seen_cursors = HashSet::new(); + let mut seen_tools = HashSet::new(); + let mut staged = Vec::new(); + let mut total_tool_bytes = 0usize; + + loop { + if seen_cursors.len() >= limits.max_pages { + return Err(DiscoveryError::TooManyPages { + maximum: limits.max_pages, + }); + } + if !seen_cursors.insert(cursor.clone()) { + return Err(DiscoveryError::CursorCycle); + } + + let page = fetcher + .fetch_tools_page(cursor.as_deref()) + .await + .map_err(|error| DiscoveryError::Fetch(Box::new(error)))?; + let next_count = staged + .len() + .checked_add(page.tools.len()) + .filter(|count| *count <= limits.max_tools) + .ok_or(DiscoveryError::TooManyTools { + maximum: limits.max_tools, + })?; + staged.reserve(next_count - staged.len()); + + for tool in page.tools { + let tool_bytes = serde_json::to_vec(&tool) + .map_err(|_| DiscoveryError::ToolMetadataTooLarge)? + .len(); + if tool_bytes > limits.max_tool_bytes { + return Err(invalid_tool(&tool.name, "tool metadata is too large")); + } + total_tool_bytes = total_tool_bytes + .checked_add(tool_bytes) + .filter(|bytes| *bytes <= limits.max_total_tool_bytes) + .ok_or(DiscoveryError::ToolMetadataTooLarge)?; + validate_tool(&tool, limits)?; + if !seen_tools.insert(tool.name.clone()) { + return Err(DiscoveryError::DuplicateTool { name: tool.name }); + } + staged.push(tool); + } + + cursor = match page.next_cursor { + Some(next) => { + if next.is_empty() + || next.len() > limits.max_cursor_bytes + || next.chars().any(char::is_control) + { + return Err(DiscoveryError::InvalidCursor); + } + let next = Some(next); + if seen_cursors.contains(&next) { + return Err(DiscoveryError::CursorCycle); + } + next + } + None => break, + }; + } + + staged.sort_by(|left, right| left.name.cmp(&right.name)); + let tools = staged.iter().map(staged_tool).collect(); + let bindings = staged + .into_iter() + .map(|tool| StagedToolBinding { + stable_key: tool.name.clone(), + binding: ToolBinding::McpHttpV1(McpToolBindingV1 { + version: 1, + tool_name: tool.name, + }), + }) + .collect(); + + Ok(DiscoveryPlan { + basis, + artifacts: vec![artifact], + tools, + bindings, + }) +} + +pub fn bindings_for_source_kind(mut plan: DiscoveryPlan, source_is_stdio: bool) -> DiscoveryPlan { + if source_is_stdio { + for binding in &mut plan.bindings { + if let ToolBinding::McpHttpV1(definition) = &binding.binding { + binding.binding = ToolBinding::McpStdioV1(definition.clone()); + } + } + } + plan +} + +fn capabilities_artifact( + basis: &DiscoveryBasis, + limits: DiscoveryLimits, +) -> Result { + if !basis.capabilities.is_object() { + return Err(DiscoveryError::InvalidCapabilities); + } + let content = json!({ + "protocolVersion": basis.protocol_version, + "serverInfo": { + "name": basis.server_name, + "version": basis.server_version, + "title": basis.server_title, + }, + "instructions": basis.instructions, + "capabilities": basis.capabilities, + "toolsListChanged": basis.tools_list_changed, + }); + if serde_json::to_vec(&content) + .map(|encoded| encoded.len() > limits.max_capabilities_bytes) + .unwrap_or(true) + { + return Err(DiscoveryError::CapabilitiesTooLarge); + } + Ok(StagedArtifact { + kind: ArtifactKind::McpCapabilities, + stable_key: CAPABILITIES_ARTIFACT_KEY.to_owned(), + content, + }) +} + +fn validate_tool(tool: &DiscoveredMcpTool, limits: DiscoveryLimits) -> Result<(), DiscoveryError> { + validate_text( + &tool.name, + limits.max_tool_name_characters, + &tool.name, + "name must contain 1 to 128 non-control characters", + false, + )?; + if tool.name.trim() != tool.name { + return Err(invalid_tool( + &tool.name, + "name must not have leading or trailing whitespace", + )); + } + if let Some(title) = tool.title.as_deref() + && !title.is_empty() + { + validate_text( + title, + limits.max_title_characters, + &tool.name, + "title is too long or contains control characters", + false, + )?; + } + if let Some(description) = tool.description.as_deref() + && !description.is_empty() + { + validate_text( + description, + limits.max_description_characters, + &tool.name, + "description is too long or contains disallowed control characters", + true, + )?; + } + if let Some(title) = tool + .annotations + .as_ref() + .and_then(|annotations| annotations.title.as_deref()) + && !title.is_empty() + { + validate_text( + title, + limits.max_title_characters, + &tool.name, + "annotation title is too long or contains control characters", + false, + )?; + } + validate_schema(&tool.input_schema, &tool.name, limits.max_schema_bytes)?; + if let Some(schema) = &tool.output_schema { + validate_schema(schema, &tool.name, limits.max_schema_bytes)?; + } + Ok(()) +} + +fn validate_text( + value: &str, + maximum: usize, + tool: &str, + message: &'static str, + allow_layout_controls: bool, +) -> Result<(), DiscoveryError> { + if value.is_empty() + || value.chars().count() > maximum + || value.chars().any(|character| { + character.is_control() && !(allow_layout_controls && matches!(character, '\n' | '\t')) + }) + { + return Err(invalid_tool(tool, message)); + } + Ok(()) +} + +fn validate_schema(schema: &Value, tool: &str, maximum_bytes: usize) -> Result<(), DiscoveryError> { + schema + .as_object() + .ok_or_else(|| invalid_tool(tool, "schema must be a JSON object"))?; + if serde_json::to_vec(schema) + .map(|encoded| encoded.len() > maximum_bytes) + .unwrap_or(true) + { + return Err(invalid_tool(tool, "schema is too large")); + } + Ok(()) +} + +fn invalid_tool(tool: &str, message: &'static str) -> DiscoveryError { + DiscoveryError::InvalidTool { + tool: tool.to_owned(), + message, + } +} + +fn staged_tool(tool: &DiscoveredMcpTool) -> StagedTool { + let intrinsic_mode = match tool.annotations.as_ref() { + Some(annotations) + if annotations.read_only_hint == Some(true) + && annotations.destructive_hint != Some(true) => + { + ToolMode::Enabled + } + _ => ToolMode::Ask, + }; + StagedTool { + stable_key: tool.name.clone(), + preferred_name: tool.name.clone(), + display_name: tool + .title + .clone() + .filter(|title| !title.is_empty()) + .or_else(|| { + tool.annotations + .as_ref() + .and_then(|annotations| annotations.title.clone()) + .filter(|title| !title.is_empty()) + }) + .unwrap_or_else(|| tool.name.clone()), + description: tool + .description + .clone() + .filter(|description| !description.is_empty()), + input_schema: tool.input_schema.clone(), + output_schema: tool.output_schema.clone(), + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode, + } +} + +#[derive(Clone, Default)] +pub struct ListChangedCoalescer { + state: Arc>, +} + +#[derive(Default)] +struct CoalescerState { + requested_generation: u64, + completed_generation: u64, + running: bool, +} + +impl ListChangedCoalescer { + /// Marks the catalog dirty and returns whether no refresh runner is currently active. + /// + /// A notification callback may use the return value as a hint to spawn a runner. More than + /// one callback can observe `true` before the first runner starts, which is harmless because + /// `refresh_pending` admits only one runner. + pub fn notify_changed(&self) -> bool { + let mut state = self.state.lock().expect("coalescer mutex poisoned"); + state.requested_generation = state.requested_generation.saturating_add(1); + !state.running + } + + pub fn has_pending(&self) -> bool { + let state = self.state.lock().expect("coalescer mutex poisoned"); + state.requested_generation != state.completed_generation + } + + pub async fn refresh_pending(&self, mut refresh: F) -> Result + where + F: FnMut() -> Fut, + Fut: Future>, + { + let Some(mut ownership) = CoalescerOwnership::acquire(self.state.clone()) else { + return Ok(0); + }; + let mut refresh_count = 0; + loop { + let Some(target) = ownership.next_target_or_finish() else { + return Ok(refresh_count); + }; + refresh().await?; + refresh_count += 1; + ownership.complete(target); + } + } +} + +struct CoalescerOwnership { + state: Arc>, + finished: bool, +} + +impl CoalescerOwnership { + fn acquire(state: Arc>) -> Option { + { + let mut current = state.lock().expect("coalescer mutex poisoned"); + if current.running { + return None; + } + current.running = true; + } + Some(Self { + state, + finished: false, + }) + } + + fn next_target_or_finish(&mut self) -> Option { + let mut state = self.state.lock().expect("coalescer mutex poisoned"); + if state.requested_generation == state.completed_generation { + state.running = false; + self.finished = true; + None + } else { + Some(state.requested_generation) + } + } + + fn complete(&mut self, generation: u64) { + self.state + .lock() + .expect("coalescer mutex poisoned") + .completed_generation = generation; + } +} + +impl Drop for CoalescerOwnership { + fn drop(&mut self) { + if !self.finished { + self.state.lock().expect("coalescer mutex poisoned").running = false; + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/mcp/discovery/tests.rs b/src/mcp/discovery/tests.rs new file mode 100644 index 000000000..c095da430 --- /dev/null +++ b/src/mcp/discovery/tests.rs @@ -0,0 +1,566 @@ +use std::{collections::VecDeque, convert::Infallible, sync::Arc}; + +use serde_json::json; +use tokio::sync::{Barrier, Mutex}; + +use super::*; +use crate::{ + AppConfig, ExecutorApp, + catalog::{ + AuditContext, CatalogError, CreateSource, CredentialPayload, ListToolsFilter, SourceKind, + ToolBinding, + }, +}; + +struct Fetcher { + pages: VecDeque, + cursors: Vec>, +} + +struct FailingFetcher { + calls: usize, +} + +#[async_trait] +impl ToolPageFetcher for FailingFetcher { + type Error = std::io::Error; + + async fn fetch_tools_page(&mut self, _cursor: Option<&str>) -> Result { + self.calls += 1; + if self.calls == 1 { + Ok(ToolPage { + tools: vec![tool("partial")], + next_cursor: Some("next".to_owned()), + }) + } else { + Err(std::io::Error::other("transient upstream failure")) + } + } +} + +#[async_trait] +impl ToolPageFetcher for Fetcher { + type Error = Infallible; + + async fn fetch_tools_page(&mut self, cursor: Option<&str>) -> Result { + self.cursors.push(cursor.map(str::to_owned)); + Ok(self.pages.pop_front().expect("test page exists")) + } +} + +fn basis() -> DiscoveryBasis { + DiscoveryBasis { + expected_source_revision: 7, + expected_credential_revision: Some(3), + protocol_version: "2025-11-25".to_owned(), + server_name: "fixture".to_owned(), + server_version: "1.2.3".to_owned(), + server_title: Some("Fixture server".to_owned()), + instructions: None, + capabilities: json!({ "tools": { "listChanged": true } }), + tools_list_changed: true, + } +} + +fn tool(name: &str) -> DiscoveredMcpTool { + DiscoveredMcpTool { + name: name.to_owned(), + title: None, + description: Some(format!("Run {name}")), + input_schema: json!({ "type": "object", "properties": {} }), + output_schema: None, + annotations: None, + meta: Map::new(), + } +} + +fn read_only_tool(name: &str) -> DiscoveredMcpTool { + DiscoveredMcpTool { + annotations: Some(McpToolAnnotations { + read_only_hint: Some(true), + destructive_hint: Some(false), + ..McpToolAnnotations::default() + }), + ..tool(name) + } +} + +#[tokio::test] +async fn pages_all_tools_before_returning_an_atomic_stable_plan() { + let mut fetcher = Fetcher { + pages: VecDeque::from([ + ToolPage { + tools: vec![tool("zeta")], + next_cursor: Some("page-2".to_owned()), + }, + ToolPage { + tools: vec![read_only_tool("alpha")], + next_cursor: None, + }, + ]), + cursors: Vec::new(), + }; + let plan = discover(&mut fetcher, basis()) + .await + .expect("discovery succeeds"); + + assert_eq!(fetcher.cursors, [None, Some("page-2".to_owned())]); + assert_eq!( + plan.tools + .iter() + .map(|tool| tool.stable_key.as_str()) + .collect::>(), + ["alpha", "zeta"] + ); + assert_eq!(plan.catalog_snapshot().expected_source_revision, 7); + assert_eq!( + plan.catalog_snapshot().expected_credential_revision, + Some(3) + ); + assert_eq!(plan.artifacts[0].kind, ArtifactKind::McpCapabilities); + assert_eq!(plan.tools[0].stable_key, "alpha"); + assert_eq!(plan.tools[0].intrinsic_mode, ToolMode::Enabled); + assert_eq!(plan.tools[1].stable_key, "zeta"); + assert_eq!(plan.tools[1].intrinsic_mode, ToolMode::Ask); + assert!(plan.bindings.iter().all(|binding| matches!( + &binding.binding, + ToolBinding::McpHttpV1(definition) if definition.tool_name == binding.stable_key + ))); +} + +#[tokio::test] +async fn exact_name_is_identity_across_reordering_and_stdio_conversion() { + let page = |tools| Fetcher { + pages: VecDeque::from([ToolPage { + tools, + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let mut first = page(vec![tool("a.b"), tool("a-b")]); + let mut second = page(vec![tool("a-b"), tool("a.b")]); + let first = discover(&mut first, basis()).await.unwrap(); + let second = bindings_for_source_kind(discover(&mut second, basis()).await.unwrap(), true); + assert_eq!( + first + .tools + .iter() + .map(|tool| &tool.stable_key) + .collect::>(), + second + .tools + .iter() + .map(|tool| &tool.stable_key) + .collect::>() + ); + assert!( + second + .bindings + .iter() + .all(|binding| matches!(binding.binding, ToolBinding::McpStdioV1(_))) + ); +} + +#[tokio::test] +async fn destructive_annotations_choose_conservative_intrinsic_modes() { + let mut destructive = tool("destructive"); + destructive.annotations = Some(McpToolAnnotations { + read_only_hint: Some(true), + destructive_hint: Some(true), + ..McpToolAnnotations::default() + }); + let mut non_destructive = tool("non-destructive"); + non_destructive.annotations = Some(McpToolAnnotations { + destructive_hint: Some(false), + ..McpToolAnnotations::default() + }); + let mut fetcher = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![ + destructive, + non_destructive, + tool("unspecified"), + read_only_tool("safe"), + ], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let plan = discover(&mut fetcher, basis()).await.unwrap(); + let modes = plan + .tools + .iter() + .map(|tool| (tool.stable_key.as_str(), tool.intrinsic_mode)) + .collect::>(); + assert_eq!( + modes, + [ + ("destructive", ToolMode::Ask), + ("non-destructive", ToolMode::Ask), + ("safe", ToolMode::Enabled), + ("unspecified", ToolMode::Ask), + ] + ); +} + +#[tokio::test] +async fn rejects_cursor_cycles_duplicates_invalid_schemas_and_limits_without_partial_plan() { + let mut failed = FailingFetcher { calls: 0 }; + assert!(matches!( + discover(&mut failed, basis()).await, + Err(DiscoveryError::Fetch(_)) + )); + + let mut cycle = Fetcher { + pages: VecDeque::from([ + ToolPage { + tools: vec![], + next_cursor: Some("again".to_owned()), + }, + ToolPage { + tools: vec![], + next_cursor: Some("again".to_owned()), + }, + ]), + cursors: Vec::new(), + }; + assert!(matches!( + discover(&mut cycle, basis()).await, + Err(DiscoveryError::CursorCycle) + )); + + let mut duplicates = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![tool("same"), tool("same")], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + assert!(matches!( + discover(&mut duplicates, basis()).await, + Err(DiscoveryError::DuplicateTool { .. }) + )); + + let mut invalid = tool("invalid"); + invalid.input_schema = json!(true); + let mut invalid = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![invalid], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + assert!(matches!( + discover(&mut invalid, basis()).await, + Err(DiscoveryError::InvalidTool { .. }) + )); + + let mut limited = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![tool("one"), tool("two")], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let limits = DiscoveryLimits { + max_tools: 1, + ..DiscoveryLimits::default() + }; + assert!(matches!( + discover_with_limits(&mut limited, basis(), limits).await, + Err(DiscoveryError::TooManyTools { maximum: 1 }) + )); + + let mut variant = tool("schema-variants"); + variant.title = Some(String::new()); + variant.description = Some(String::new()); + variant.annotations = Some(McpToolAnnotations { + title: Some(String::new()), + ..McpToolAnnotations::default() + }); + variant.input_schema = json!({}); + variant.output_schema = Some(json!({ "allOf": [{ "type": "object" }] })); + let mut schema_variants = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![variant], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let normalized = discover(&mut schema_variants, basis()) + .await + .expect("object schemas need not declare type at the root"); + assert_eq!(normalized.tools[0].display_name, "schema-variants"); + assert_eq!(normalized.tools[0].description, None); +} + +#[tokio::test] +async fn list_changed_bursts_coalesce_and_failed_refresh_remains_pending() { + let coalescer = ListChangedCoalescer::default(); + assert!(coalescer.notify_changed()); + let started = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let calls = Arc::new(Mutex::new(0usize)); + let runner = { + let coalescer = coalescer.clone(); + let started = started.clone(); + let release = release.clone(); + let calls = calls.clone(); + tokio::spawn(async move { + coalescer + .refresh_pending(|| { + let started = started.clone(); + let release = release.clone(); + let calls = calls.clone(); + async move { + let mut count = calls.lock().await; + *count += 1; + let first = *count == 1; + drop(count); + if first { + started.wait().await; + release.wait().await; + } + Ok::<_, &'static str>(()) + } + }) + .await + }) + }; + started.wait().await; + for _ in 0..20 { + assert!(!coalescer.notify_changed()); + } + release.wait().await; + assert_eq!(runner.await.unwrap().unwrap(), 2); + assert_eq!(*calls.lock().await, 2); + + assert!(coalescer.notify_changed()); + assert_eq!( + coalescer + .refresh_pending(|| async { Err::<(), _>("transient") }) + .await, + Err("transient") + ); + assert!(coalescer.has_pending()); + assert_eq!( + coalescer + .refresh_pending(|| async { Ok::<_, &'static str>(()) }) + .await + .unwrap(), + 1 + ); + assert!(!coalescer.has_pending()); +} + +#[tokio::test] +async fn catalog_commit_is_atomic_cas_guarded_and_preserves_tombstone_identity() { + let directory = tempfile::tempdir().unwrap(); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .unwrap(); + let mut initial_fetcher = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![tool("alpha"), tool("beta")], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let initial = discover(&mut initial_fetcher, basis()).await.unwrap(); + let (source, _) = app + .catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::McpHttp, + preferred_slug: "mcp-fixture".to_owned(), + display_name: "MCP fixture".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({}), + }, + initial.initial_catalog_snapshot(), + initial.bindings, + AuditContext::system(Some("mcp-create")), + ) + .await + .unwrap(); + let mut wrong_kind_fetcher = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![tool("alpha"), tool("beta")], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let mut wrong_kind_basis = basis(); + wrong_kind_basis.expected_source_revision = source.revision; + wrong_kind_basis.expected_credential_revision = Some(0); + let wrong_kind = bindings_for_source_kind( + discover(&mut wrong_kind_fetcher, wrong_kind_basis) + .await + .unwrap(), + true, + ); + assert!(matches!( + app.catalog() + .sync_catalog_with_bindings( + &source.id, + wrong_kind.catalog_snapshot(), + wrong_kind.bindings, + AuditContext::system(Some("mcp-wrong-kind")), + ) + .await, + Err(CatalogError::Validation { + code: "invalid_source_kind", + .. + }) + )); + assert_eq!( + app.catalog().source(&source.id).await.unwrap().revision, + source.revision + ); + let mut mismatched_fetcher = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![tool("alpha"), tool("beta")], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let mut mismatch_basis = basis(); + mismatch_basis.expected_source_revision = source.revision; + mismatch_basis.expected_credential_revision = Some(0); + let mut mismatched = discover(&mut mismatched_fetcher, mismatch_basis) + .await + .unwrap(); + let ToolBinding::McpHttpV1(definition) = &mut mismatched.bindings[0].binding else { + panic!("HTTP discovery creates HTTP bindings") + }; + definition.tool_name = "another-upstream-tool".to_owned(); + assert!(matches!( + app.catalog() + .sync_catalog_with_bindings( + &source.id, + mismatched.catalog_snapshot(), + mismatched.bindings, + AuditContext::system(Some("mcp-mismatched-name")), + ) + .await, + Err(CatalogError::Validation { + code: "invalid_tool_binding", + .. + }) + )); + let initial_page = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + include_tombstoned: true, + limit: 100, + ..ListToolsFilter::default() + }) + .await + .unwrap(); + let beta_id = initial_page + .items + .iter() + .find(|tool| tool.stable_key == "beta") + .unwrap() + .id + .clone(); + + let mut refresh_basis = basis(); + refresh_basis.expected_source_revision = source.revision; + refresh_basis.expected_credential_revision = Some(0); + let mut removed_fetcher = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![tool("alpha")], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let removed = discover(&mut removed_fetcher, refresh_basis).await.unwrap(); + app.catalog() + .sync_catalog_with_bindings( + &source.id, + removed.catalog_snapshot(), + removed.bindings.clone(), + AuditContext::system(Some("mcp-remove")), + ) + .await + .unwrap(); + let stale_error = app + .catalog() + .sync_catalog_with_bindings( + &source.id, + removed.catalog_snapshot(), + removed.bindings, + AuditContext::system(Some("mcp-stale")), + ) + .await + .unwrap_err(); + assert!(matches!( + stale_error, + CatalogError::RevisionConflict { + scope: "source", + .. + } + )); + let tombstoned_page = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + include_tombstoned: true, + limit: 100, + ..ListToolsFilter::default() + }) + .await + .unwrap(); + let tombstoned = tombstoned_page + .items + .iter() + .find(|tool| tool.stable_key == "beta") + .unwrap(); + assert_eq!(tombstoned.id, beta_id); + assert!(!tombstoned.present); + + let refreshed_source = app.catalog().source(&source.id).await.unwrap(); + let mut restore_basis = basis(); + restore_basis.expected_source_revision = refreshed_source.revision; + restore_basis.expected_credential_revision = Some(0); + let mut restore_fetcher = Fetcher { + pages: VecDeque::from([ToolPage { + tools: vec![tool("beta"), tool("alpha")], + next_cursor: None, + }]), + cursors: Vec::new(), + }; + let restored = discover(&mut restore_fetcher, restore_basis).await.unwrap(); + app.catalog() + .sync_catalog_with_bindings( + &source.id, + restored.catalog_snapshot(), + restored.bindings, + AuditContext::system(Some("mcp-restore")), + ) + .await + .unwrap(); + let restored_page = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id), + include_tombstoned: true, + limit: 100, + ..ListToolsFilter::default() + }) + .await + .unwrap(); + let restored_beta = restored_page + .items + .iter() + .find(|tool| tool.stable_key == "beta") + .unwrap(); + assert_eq!(restored_beta.id, beta_id); + assert!(restored_beta.present); +} diff --git a/src/mcp/downstream/mod.rs b/src/mcp/downstream/mod.rs new file mode 100644 index 000000000..9dd8fd817 --- /dev/null +++ b/src/mcp/downstream/mod.rs @@ -0,0 +1,2005 @@ +use std::{ + collections::HashMap, + future::Future, + sync::atomic::{AtomicBool, Ordering}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use axum::{ + Router, + extract::{DefaultBodyLimit, Extension, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, +}; +use rmcp::model::ProtocolVersion; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore}; +use url::Url; +use uuid::Uuid; + +use crate::{ + actor::ToolActor, + catalog::{CatalogError, CatalogStore, RequestSurface}, + database::Database, + execution::{ExecuteCodeRequest, ExecutionService, ExecutionServiceError}, + invocation::{ + GatewayInvokeError, McpIdempotencyBlob, McpIdempotencyClaim, McpIdempotencyRequest, + ToolCall, ToolCallError, ToolCallService, ToolResult, + }, + tasks::TaskTracker, + unix_timestamp, +}; + +const PROTOCOL_VERSION: &str = "2025-11-25"; +const SESSION_HEADER: &str = "mcp-session-id"; +const PROTOCOL_HEADER: &str = "mcp-protocol-version"; +const MAX_MCP_BODY_BYTES: usize = crate::invocation::MAX_ARGUMENT_BYTES + 64 * 1024; +const MAX_BEARER_TOKEN_BYTES: usize = 512; +const MAX_SESSIONS: usize = 4_096; +const MAX_SESSIONS_PER_TOKEN: usize = 32; +const MAX_ACTIVE_REQUESTS: usize = 4_096; +const MAX_PRE_CANCELS_PER_SESSION: usize = 128; +const MAX_CONCURRENT_BODIES: usize = 16; +const MAX_CONCURRENT_BODIES_PER_TOKEN: usize = 4; +const MAX_CONCURRENT_EXECUTIONS: usize = 16; +const MAX_CONCURRENT_EXECUTIONS_PER_TOKEN: usize = 4; +const SESSION_TTL: Duration = Duration::from_secs(8 * 60 * 60); +const UNINITIALIZED_SESSION_TTL: Duration = Duration::from_secs(5 * 60); +const DEFAULT_EXECUTION_TIMEOUT_MILLIS: u64 = 30_000; +const MAX_EXECUTION_TIMEOUT_MILLIS: u64 = 300_000; +const PRE_CANCEL_TTL: Duration = Duration::from_secs(60); + +#[derive(Clone)] +pub(crate) struct McpState { + database: Database, + _catalog: CatalogStore, + tool_calls: ToolCallService, + execution: ExecutionService, + origin: Arc, + origin_url: Url, + sessions: Arc>>, + cancellations: CancellationRegistry, + tasks: TaskTracker, + body_limiter: McpRequestLimiter, + execution_limiter: McpRequestLimiter, + #[cfg(test)] + session_registration_race_hook: Arc>>>, +} + +#[derive(Clone)] +struct McpRequestLimiter { + global: Arc, + by_token: Arc>>>, + per_token: usize, +} + +struct McpRequestPermits { + _global: OwnedSemaphorePermit, + _token: OwnedSemaphorePermit, +} + +impl McpRequestLimiter { + fn new(global: usize, per_token: usize) -> Self { + Self { + global: Arc::new(Semaphore::new(global)), + by_token: Arc::new(Mutex::new(HashMap::new())), + per_token, + } + } + + fn try_acquire(&self, token_id: &str) -> Option> { + let global = self.global.clone().try_acquire_owned().ok()?; + let token_slots = { + let mut slots = self + .by_token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + slots + .entry(token_id.to_owned()) + .or_insert_with(|| Arc::new(Semaphore::new(self.per_token))) + .clone() + }; + let token = token_slots.try_acquire_owned().ok()?; + Some(Arc::new(McpRequestPermits { + _global: global, + _token: token, + })) + } +} + +#[derive(Clone, Default)] +struct CancellationRegistry { + entries: Arc>>, +} + +struct CancellationEntry { + notify: Arc, + canceled: Arc, + waiters: usize, + created_at: Instant, +} + +struct CancellationRegistration { + key: String, + notify: Arc, + canceled: Arc, + registry: CancellationRegistry, +} + +#[derive(Clone)] +struct CancellationSignal { + notify: Arc, + canceled: Arc, +} + +enum SessionRequestRegistrationError { + SessionEnded, + Capacity, +} + +#[cfg(test)] +#[derive(Default)] +struct SessionRegistrationRaceHook { + validation_complete: Notify, + continue_registration: Notify, +} + +#[derive(Clone)] +struct Session { + token_id: String, + token_name: String, + initialized: bool, + last_seen: Instant, +} + +#[derive(Clone)] +struct Identity { + token_id: String, + token_name: String, +} + +#[derive(Debug, Deserialize)] +struct IncomingMessage { + jsonrpc: Option, + id: Option, + method: Option, + #[serde(default)] + params: Value, +} + +struct EnvelopeShape { + has_method: bool, + has_params: bool, + has_result: bool, + has_error: bool, + error_valid: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct InitializeParams { + protocol_version: ProtocolVersion, + capabilities: Value, + client_info: Value, + #[serde(rename = "_meta")] + meta: Option, +} + +#[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] +struct ListToolsParams { + cursor: Option, + #[serde(rename = "_meta")] + meta: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CallToolParams { + name: String, + #[serde(default = "empty_arguments")] + arguments: Map, + #[serde(rename = "_meta")] + meta: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExecuteArguments { + code: String, + timeout_ms: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DirectCallArguments { + path: String, + #[serde(default = "empty_arguments")] + arguments: Map, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SearchArguments { + query: String, + namespace: Option, + #[serde(default = "default_search_limit")] + limit: u32, + #[serde(default)] + offset: u32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DescribeArguments { + path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CancelledParams { + request_id: Value, +} + +#[derive(Serialize)] +struct McpTool { + name: String, + title: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(rename = "inputSchema")] + input_schema: Value, + #[serde(rename = "outputSchema", skip_serializing_if = "Option::is_none")] + output_schema: Option, +} + +impl McpState { + pub(crate) fn new( + database: Database, + catalog: CatalogStore, + tool_calls: ToolCallService, + execution: ExecutionService, + tasks: TaskTracker, + origin: String, + ) -> Self { + let origin_url = Url::parse(&origin).expect("validated application origins are URLs"); + Self { + database, + _catalog: catalog, + tool_calls, + execution, + origin: Arc::from(origin), + origin_url, + sessions: Arc::new(Mutex::new(HashMap::new())), + cancellations: CancellationRegistry::default(), + tasks, + body_limiter: McpRequestLimiter::new( + MAX_CONCURRENT_BODIES, + MAX_CONCURRENT_BODIES_PER_TOKEN, + ), + execution_limiter: McpRequestLimiter::new( + MAX_CONCURRENT_EXECUTIONS, + MAX_CONCURRENT_EXECUTIONS_PER_TOKEN, + ), + #[cfg(test)] + session_registration_race_hook: Arc::new(Mutex::new(None)), + } + } + + pub(crate) fn revoke_token(&self, token_id: &str) { + let mut sessions = self + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let revoked_sessions = sessions + .iter() + .filter(|(_, session)| session.token_id == token_id) + .map(|(session_id, _)| session_id.clone()) + .collect::>(); + for session_id in revoked_sessions { + sessions.remove(&session_id); + self.cancellations.cancel_session(&session_id); + } + } + + fn create_session(&self, identity: &Identity) -> Result { + let now = Instant::now(); + let mut sessions = self + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.prune_expired_sessions(&mut sessions, now); + if sessions + .values() + .filter(|session| session.token_id == identity.token_id) + .count() + >= MAX_SESSIONS_PER_TOKEN + { + return Err(TransportError::service_unavailable( + "session_capacity", + "The API token has reached its MCP session limit.", + )); + } + if sessions.len() >= MAX_SESSIONS { + return Err(TransportError::service_unavailable( + "session_capacity", + "The MCP session limit has been reached.", + )); + } + let id = Uuid::new_v4().to_string(); + sessions.insert( + id.clone(), + Session { + token_id: identity.token_id.clone(), + token_name: identity.token_name.clone(), + initialized: false, + last_seen: now, + }, + ); + Ok(id) + } + + fn require_session( + &self, + headers: &HeaderMap, + identity: &Identity, + require_initialized: bool, + ) -> Result<(String, Session), TransportError> { + require_protocol_version(headers)?; + let id = unique_text_header(headers, SESSION_HEADER)?.ok_or_else(|| { + TransportError::bad_request( + "missing_session", + "Mcp-Session-Id is required after initialization.", + ) + })?; + if id.len() > 128 || !id.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { + return Err(TransportError::bad_request( + "invalid_session", + "Mcp-Session-Id is invalid.", + )); + } + let now = Instant::now(); + let mut sessions = self + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.prune_expired_sessions(&mut sessions, now); + let session = sessions.get_mut(id).ok_or_else(|| { + TransportError::not_found("session_not_found", "The MCP session does not exist.") + })?; + if session.token_id != identity.token_id { + return Err(TransportError::not_found( + "session_not_found", + "The MCP session does not exist.", + )); + } + if require_initialized && !session.initialized { + return Err(TransportError::bad_request( + "session_not_initialized", + "The MCP initialization handshake is incomplete.", + )); + } + session.last_seen = now; + Ok((id.to_owned(), session.clone())) + } + + fn register_session_request( + &self, + session_id: &str, + token_id: &str, + request_key: String, + ) -> Result { + let mut sessions = self + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.prune_expired_sessions(&mut sessions, Instant::now()); + let active = sessions + .get(session_id) + .is_some_and(|session| session.initialized && session.token_id == token_id); + if !active { + return Err(SessionRequestRegistrationError::SessionEnded); + } + self.cancellations + .register(request_key) + .ok_or(SessionRequestRegistrationError::Capacity) + } + + fn terminate_session(&self, session_id: &str, token_id: &str) -> bool { + let mut sessions = self + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let owned = sessions + .get(session_id) + .is_some_and(|session| session.token_id == token_id); + if owned { + sessions.remove(session_id); + self.cancellations.cancel_session(session_id); + } + owned + } + + fn prune_expired_sessions(&self, sessions: &mut HashMap, now: Instant) { + let expired = sessions + .iter() + .filter(|(_, session)| { + let ttl = if session.initialized { + SESSION_TTL + } else { + UNINITIALIZED_SESSION_TTL + }; + now.saturating_duration_since(session.last_seen) >= ttl + }) + .map(|(session_id, _)| session_id.clone()) + .collect::>(); + for session_id in expired { + sessions.remove(&session_id); + self.cancellations.cancel_session(&session_id); + } + } +} + +impl CancellationRegistry { + fn register(&self, key: String) -> Option { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = Instant::now(); + entries.retain(|_, entry| { + entry.waiters > 0 || now.saturating_duration_since(entry.created_at) < PRE_CANCEL_TTL + }); + if !entries.contains_key(&key) && entries.len() >= MAX_ACTIVE_REQUESTS { + return None; + } + let entry = entries + .entry(key.clone()) + .or_insert_with(|| CancellationEntry { + notify: Arc::new(Notify::new()), + canceled: Arc::new(AtomicBool::new(false)), + waiters: 0, + created_at: now, + }); + entry.waiters = entry.waiters.saturating_add(1); + Some(CancellationRegistration { + key, + notify: entry.notify.clone(), + canceled: entry.canceled.clone(), + registry: self.clone(), + }) + } + + fn cancel(&self, key: &str) { + let now = Instant::now(); + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + entries.retain(|_, entry| { + entry.waiters > 0 || now.saturating_duration_since(entry.created_at) < PRE_CANCEL_TTL + }); + let session_prefix = key + .split_once('\0') + .map(|(session, _)| format!("{session}\0")); + let session_tombstones = session_prefix.as_deref().map_or(0, |prefix| { + entries + .iter() + .filter(|(entry_key, entry)| entry.waiters == 0 && entry_key.starts_with(prefix)) + .count() + }); + if !entries.contains_key(key) + && entries.len() < MAX_ACTIVE_REQUESTS + && session_tombstones < MAX_PRE_CANCELS_PER_SESSION + { + entries.insert( + key.to_owned(), + CancellationEntry { + notify: Arc::new(Notify::new()), + canceled: Arc::new(AtomicBool::new(true)), + waiters: 0, + created_at: now, + }, + ); + } + if let Some(entry) = entries.get(key) { + entry.canceled.store(true, Ordering::Release); + entry.notify.notify_waiters(); + } + } + + fn cancel_session(&self, session_id: &str) { + let prefix = format!("{session_id}\0"); + let signals = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|(key, _)| key.starts_with(&prefix)) + .map(|(_, entry)| (entry.notify.clone(), entry.canceled.clone())) + .collect::>(); + for (notify, canceled) in signals { + canceled.store(true, Ordering::Release); + notify.notify_waiters(); + } + } +} + +impl Drop for CancellationRegistration { + fn drop(&mut self) { + let mut entries = self + .registry + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let remove = entries.get_mut(&self.key).is_some_and(|entry| { + if !Arc::ptr_eq(&entry.notify, &self.notify) { + return false; + } + entry.waiters = entry.waiters.saturating_sub(1); + entry.waiters == 0 + }); + if remove { + entries.remove(&self.key); + } + } +} + +pub(crate) fn router(state: McpState) -> Router +where + S: Clone + Send + Sync + 'static, +{ + let guard_state = state.clone(); + Router::new() + .route( + "/mcp", + post(handle_post) + .get(handle_get) + .delete(handle_delete) + .options(handle_options), + ) + .layer(DefaultBodyLimit::max(MAX_MCP_BODY_BYTES)) + .route_layer(middleware::from_fn_with_state( + guard_state, + connection_guard, + )) + .with_state(state) +} + +async fn connection_guard( + State(state): State, + mut request: axum::extract::Request, + next: Next, +) -> Response { + if request.method() != axum::http::Method::OPTIONS { + if let Err(error) = validate_connection(&state, request.headers()) { + return error.into_response(); + } + let identity = match authenticate(&state, request.headers()).await { + Ok(identity) => identity, + Err(error) => return error.into_response(), + }; + if request.method() == axum::http::Method::POST { + let Some(permits) = state.body_limiter.try_acquire(&identity.token_id) else { + return TransportError::too_many_requests( + "mcp_busy", + "The MCP endpoint is busy. Try again shortly.", + ) + .into_response(); + }; + request.extensions_mut().insert(permits); + } + request.extensions_mut().insert(identity); + } + next.run(request).await +} + +async fn handle_options(State(state): State, headers: HeaderMap) -> Response { + if let Err(error) = validate_origin(&state, &headers) { + return error.into_response(); + } + let mut response = StatusCode::NO_CONTENT.into_response(); + add_cors_headers(response.headers_mut(), &state); + response.headers_mut().insert( + header::ALLOW, + HeaderValue::from_static("POST, GET, DELETE, OPTIONS"), + ); + response +} + +async fn handle_get( + State(state): State, + Extension(identity): Extension, + headers: HeaderMap, +) -> Response { + if let Err(error) = validate_connection(&state, &headers) { + return error.into_response(); + } + if let Err(error) = state.require_session(&headers, &identity, true) { + return error.into_response(); + } + let mut response = StatusCode::METHOD_NOT_ALLOWED.into_response(); + response.headers_mut().insert( + header::ALLOW, + HeaderValue::from_static("POST, DELETE, OPTIONS"), + ); + add_cors_headers(response.headers_mut(), &state); + response +} + +async fn handle_delete( + State(state): State, + Extension(identity): Extension, + headers: HeaderMap, +) -> Response { + if let Err(error) = validate_connection(&state, &headers) { + return error.into_response(); + } + let (session_id, session) = match state.require_session(&headers, &identity, false) { + Ok(session) => session, + Err(error) => return error.into_response(), + }; + if !state.terminate_session(&session_id, &session.token_id) { + return TransportError::not_found("session_not_found", "The MCP session does not exist.") + .into_response(); + } + let mut response = StatusCode::NO_CONTENT.into_response(); + add_cors_headers(response.headers_mut(), &state); + response +} + +async fn handle_post( + State(state): State, + Extension(identity): Extension, + Extension(request_permits): Extension>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + drop(request_permits); + if let Err(error) = validate_connection(&state, &headers) { + return error.into_response(); + } + if let Err(error) = validate_post_headers(&headers) { + return error.into_response(); + } + let raw_message = match serde_json::from_slice::(&body) { + Ok(message) => message, + Err(_) => return rpc_error_response(None, -32700, "Parse error", None, &state), + }; + let Some(object) = raw_message.as_object() else { + return rpc_error_response(None, -32600, "Invalid Request", None, &state); + }; + let shape = EnvelopeShape { + has_method: object.contains_key("method"), + has_params: object.contains_key("params"), + has_result: object.contains_key("result"), + has_error: object.contains_key("error"), + error_valid: object.get("error").is_none_or(valid_jsonrpc_error), + }; + let message = match serde_json::from_value::(raw_message) { + Ok(message) => message, + Err(_) => return rpc_error_response(None, -32600, "Invalid Request", None, &state), + }; + if message.jsonrpc.as_deref() != Some("2.0") + || message.id.as_ref().is_some_and(|id| !valid_request_id(id)) + || (shape.has_method && message.method.is_none()) + { + return rpc_error_response(message.id, -32600, "Invalid Request", None, &state); + } + if !shape.has_method { + let response_shape = message.id.is_some() + && (shape.has_result ^ shape.has_error) + && !shape.has_params + && shape.error_valid; + if !response_shape { + return TransportError::bad_request( + "invalid_jsonrpc_message", + "The JSON-RPC response is invalid.", + ) + .into_response(); + } + if let Err(error) = state.require_session(&headers, &identity, true) { + return error.into_response(); + } + let mut response = StatusCode::ACCEPTED.into_response(); + add_cors_headers(response.headers_mut(), &state); + return response; + } + if shape.has_result || shape.has_error { + return TransportError::bad_request( + "invalid_jsonrpc_message", + "A JSON-RPC request cannot contain response fields.", + ) + .into_response(); + } + let method = message.method.as_deref().unwrap_or_default(); + if method == "initialize" { + return initialize(state, headers, identity, message).await; + } + let (session_id, session) = match state.require_session(&headers, &identity, false) { + Ok(session) => session, + Err(error) => return error.into_response(), + }; + if message.id.is_none() { + return handle_notification(state, session_id, session, message).await; + } + if !session.initialized { + return rpc_error_response( + message.id, + -32600, + "Initialization handshake incomplete", + None, + &state, + ); + } + match method { + "ping" => rpc_result_response(message.id, json!({}), None, &state), + "tools/list" => list_tools(&state, message).await, + "tools/call" => call_tool(&state, &session_id, session, message).await, + _ => rpc_error_response(message.id, -32601, "Method not found", None, &state), + } +} + +async fn initialize( + state: McpState, + headers: HeaderMap, + identity: Identity, + message: IncomingMessage, +) -> Response { + let Some(id) = message.id else { + return rpc_error_response(None, -32600, "Invalid Request", None, &state); + }; + match unique_text_header(&headers, SESSION_HEADER) { + Ok(None) => {} + Ok(Some(_)) => { + return TransportError::bad_request( + "unexpected_session", + "Initialization must not include Mcp-Session-Id.", + ) + .into_response(); + } + Err(error) => return error.into_response(), + } + let params = match serde_json::from_value::(message.params) { + Ok(params) => params, + Err(_) => return rpc_error_response(Some(id), -32602, "Invalid params", None, &state), + }; + if params.protocol_version != ProtocolVersion::V_2025_11_25 { + return rpc_error_response( + Some(id), + -32602, + "Unsupported protocol version", + Some(json!({ "supported": [PROTOCOL_VERSION] })), + &state, + ); + } + let client_info_valid = params.client_info.as_object().is_some_and(|client_info| { + client_info + .get("name") + .and_then(Value::as_str) + .is_some_and(|name| !name.is_empty()) + && client_info + .get("version") + .and_then(Value::as_str) + .is_some_and(|version| !version.is_empty()) + }); + if !params.capabilities.is_object() || !client_info_valid { + return rpc_error_response(Some(id), -32602, "Invalid params", None, &state); + } + let _ = params.meta; + let session_id = match state.create_session(&identity) { + Ok(session_id) => session_id, + Err(error) => return error.into_response(), + }; + rpc_result_response( + Some(id), + json!({ + "protocolVersion": ProtocolVersion::V_2025_11_25, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { + "name": "executor", + "title": "Executor", + "version": env!("CARGO_PKG_VERSION") + }, + "instructions": "Executor exposes the globally enabled tool catalog. Tools in Ask mode pause until approved by the administrator." + }), + Some(&session_id), + &state, + ) +} + +async fn handle_notification( + state: McpState, + session_id: String, + _session: Session, + message: IncomingMessage, +) -> Response { + if message.method.as_deref() == Some("notifications/initialized") { + let mut sessions = state + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(session) = sessions.get_mut(&session_id) { + session.initialized = true; + session.last_seen = Instant::now(); + } + } else if message.method.as_deref() == Some("notifications/cancelled") + && let Ok(params) = serde_json::from_value::(message.params) + && valid_request_id(¶ms.request_id) + { + state.cancellations.cancel(&request_cancellation_key( + &session_id, + &rpc_id_string(¶ms.request_id), + )); + } + let mut response = StatusCode::ACCEPTED.into_response(); + add_cors_headers(response.headers_mut(), &state); + response +} + +async fn list_tools(state: &McpState, message: IncomingMessage) -> Response { + let id = message.id; + let params = match decode_optional_params::(message.params) { + Ok(params) => params, + Err(()) => return rpc_error_response(id, -32602, "Invalid params", None, state), + }; + let _ = params.meta; + if params.cursor.is_some() { + return rpc_error_response(id, -32602, "Invalid cursor", None, state); + } + rpc_result_response(id, json!({ "tools": virtual_tools() }), None, state) +} + +async fn call_tool( + state: &McpState, + session_id: &str, + session: Session, + message: IncomingMessage, +) -> Response { + let id = message.id; + let params = match serde_json::from_value::(message.params) { + Ok(params) => params, + Err(_) => return rpc_error_response(id, -32602, "Invalid params", None, state), + }; + if params.name.is_empty() || params.name.len() > 512 { + return rpc_error_response(id, -32602, "Invalid params", None, state); + } + if !matches!( + params.name.as_str(), + "execute" | "call" | "search" | "describe" | "sources" + ) { + return rpc_error_response( + id, + -32602, + "The requested tool does not exist.", + None, + state, + ); + } + let _ = params.meta; + let correlation = rpc_id_string(id.as_ref().expect("request IDs are present for calls")); + #[cfg(test)] + { + let hook = state + .session_registration_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(hook) = hook { + hook.validation_complete.notify_one(); + hook.continue_registration.notified().await; + } + } + let Some(execution_permits) = state.execution_limiter.try_acquire(&session.token_id) else { + return rpc_error_response( + id, + -32603, + "The MCP execution limit was reached.", + None, + state, + ); + }; + let cancellation = match state.register_session_request( + session_id, + &session.token_id, + request_cancellation_key(session_id, &correlation), + ) { + Ok(cancellation) => cancellation, + Err(SessionRequestRegistrationError::SessionEnded) => { + return idempotency_error(id, GatewayInvokeError::Canceled, state); + } + Err(SessionRequestRegistrationError::Capacity) => { + return rpc_error_response( + id, + -32603, + "The MCP request limit was reached.", + None, + state, + ); + } + }; + if cancellation.canceled.load(Ordering::Acquire) { + return idempotency_error(id, GatewayInvokeError::Canceled, state); + } + let session_id = session_id.to_owned(); + let task_state = state.clone(); + let detached = async move { + let _execution_permits = execution_permits; + let state = &task_state; + let execution_id = format!("mcp:{session_id}:{correlation}"); + let token_id = session.token_id.clone(); + let actor = ToolActor::api_token(token_id.clone(), Some(session.token_name)); + let arguments = Value::Object(params.arguments); + let idempotency_key = mcp_idempotency_key(&session_id, &correlation); + match params.name.as_str() { + "execute" => { + let request = idempotency_request( + &token_id, + &idempotency_key, + "executor.execute", + &arguments, + ); + match prepare_execute(arguments) { + Ok(arguments) => { + run_idempotent_virtual( + state, + &request, + execute_virtual(state, &actor, &correlation, arguments), + cancellation_signal(&cancellation), + ) + .await + } + Err(error) => Err(error), + } + } + "call" => { + call_virtual( + state, + &actor, + &execution_id, + &correlation, + arguments, + &idempotency_key, + cancellation_signal(&cancellation), + ) + .await + } + "search" => { + let request = + idempotency_request(&token_id, &idempotency_key, "executor.search", &arguments); + match prepare_search(arguments) { + Ok(arguments) => { + run_idempotent_virtual( + state, + &request, + search_virtual(state, &actor, &execution_id, &correlation, arguments), + cancellation_signal(&cancellation), + ) + .await + } + Err(error) => Err(error), + } + } + "describe" => { + let request = idempotency_request( + &token_id, + &idempotency_key, + "executor.describe", + &arguments, + ); + match prepare_describe(arguments) { + Ok(arguments) => { + run_idempotent_virtual( + state, + &request, + describe_virtual(state, &actor, &execution_id, &correlation, arguments), + cancellation_signal(&cancellation), + ) + .await + } + Err(error) => Err(error), + } + } + "sources" => { + let request = idempotency_request( + &token_id, + &idempotency_key, + "executor.sources", + &arguments, + ); + match prepare_sources(arguments) { + Ok(()) => { + run_idempotent_virtual( + state, + &request, + sources_virtual(state, &actor, &execution_id, &correlation), + cancellation_signal(&cancellation), + ) + .await + } + Err(error) => Err(error), + } + } + _ => unreachable!("virtual tool names were validated before dispatch"), + } + }; + let (completed, result) = tokio::sync::oneshot::channel(); + if !state.tasks.spawn(async move { + let _ = completed.send(detached.await); + }) { + return rpc_error_response(id, -32603, "MCP service is shutting down", None, state); + } + let result = match result.await { + Ok(result) => result, + Err(_) => return rpc_error_response(id, -32603, "Internal error", None, state), + }; + match result { + Ok(result) => tool_result_response(id, result, state), + Err(VirtualToolError::Execution(error)) => { + tracing::warn!(error = %error, "MCP TypeScript execution failed"); + tool_result_response( + id, + failure_result("execution_failed", "TypeScript execution failed."), + state, + ) + } + Err(VirtualToolError::InvalidArguments) => { + rpc_error_response(id, -32602, "Invalid params", None, state) + } + Err(VirtualToolError::Idempotency(error)) => idempotency_error(id, error, state), + } +} + +fn idempotency_request( + token_id: &str, + key: &str, + callable_path: &str, + arguments: &Value, +) -> McpIdempotencyRequest { + McpIdempotencyRequest { + owner_api_token_id: token_id.to_owned(), + key: key.to_owned(), + route: "POST /mcp tools/call".to_owned(), + callable_path: callable_path.to_owned(), + arguments: arguments.clone(), + } +} + +#[derive(Debug)] +enum VirtualToolError { + InvalidArguments, + Execution(ExecutionServiceError), + Idempotency(GatewayInvokeError), +} + +async fn run_idempotent_virtual( + state: &McpState, + request: &McpIdempotencyRequest, + operation: F, + cancellation: CancellationSignal, +) -> Result +where + F: Future>, +{ + let claim = state + .tool_calls + .claim_mcp_idempotency(request) + .await + .map_err(VirtualToolError::Idempotency)?; + let reservation = match claim { + McpIdempotencyClaim::Fresh(reservation) => *reservation, + McpIdempotencyClaim::Replay(response) => return decode_idempotent_result(response), + McpIdempotencyClaim::InProgress => { + match state + .tool_calls + .wait_mcp_idempotency(request, cancellation.wait()) + .await + .map_err(VirtualToolError::Idempotency)? + { + McpIdempotencyClaim::Fresh(reservation) => *reservation, + McpIdempotencyClaim::Replay(response) => { + return decode_idempotent_result(response); + } + McpIdempotencyClaim::Indeterminate => { + return Err(VirtualToolError::Idempotency( + GatewayInvokeError::OutcomeUnknown, + )); + } + McpIdempotencyClaim::Mismatch => { + return Err(VirtualToolError::Idempotency( + GatewayInvokeError::KeyMismatch, + )); + } + McpIdempotencyClaim::InProgress => { + return Err(VirtualToolError::Idempotency( + GatewayInvokeError::InProgress, + )); + } + } + } + McpIdempotencyClaim::Indeterminate => { + return Err(VirtualToolError::Idempotency( + GatewayInvokeError::OutcomeUnknown, + )); + } + McpIdempotencyClaim::Mismatch => { + return Err(VirtualToolError::Idempotency( + GatewayInvokeError::KeyMismatch, + )); + } + }; + let execution = reservation + .mark_executing() + .await + .map_err(VirtualToolError::Idempotency)?; + let result = tokio::select! { + result = operation => match result { + Ok(result) => result, + Err(error) => { + execution + .mark_indeterminate() + .await + .map_err(VirtualToolError::Idempotency)?; + return Err(error); + } + }, + () = cancellation.wait() => { + execution + .mark_indeterminate() + .await + .map_err(VirtualToolError::Idempotency)?; + return Err(VirtualToolError::Idempotency(GatewayInvokeError::Canceled)); + } + }; + let body = serde_json::to_vec(&result).map_err(|_| { + VirtualToolError::Idempotency(GatewayInvokeError::Idempotency( + "MCP result serialization failed".to_owned(), + )) + })?; + execution + .complete(McpIdempotencyBlob { body }) + .await + .map_err(VirtualToolError::Idempotency)?; + Ok(result) +} + +fn decode_idempotent_result(response: McpIdempotencyBlob) -> Result { + serde_json::from_slice(&response.body).map_err(|_| { + VirtualToolError::Idempotency(GatewayInvokeError::Idempotency( + "stored MCP response was invalid".to_owned(), + )) + }) +} + +async fn call_virtual( + state: &McpState, + actor: &ToolActor, + execution_id: &str, + call_id: &str, + arguments: Value, + idempotency_key: &str, + cancellation: CancellationSignal, +) -> Result { + let arguments = serde_json::from_value::(arguments) + .map_err(|_| VirtualToolError::InvalidArguments)?; + if arguments.path.is_empty() || arguments.path.len() > 512 { + return Err(VirtualToolError::InvalidArguments); + } + let response = state + .tool_calls + .submit_mcp_idempotent( + ToolCall { + request_id: Uuid::new_v4().to_string(), + actor: actor.clone(), + surface: RequestSurface::Mcp, + execution_id: execution_id.to_owned(), + call_id: call_id.to_owned(), + worker_generation: 0, + path: arguments.path, + arguments: Value::Object(arguments.arguments), + }, + "POST /mcp tools/call", + idempotency_key, + cancellation.wait(), + ) + .await + .map_err(VirtualToolError::Idempotency)?; + if response.replayed { + tracing::debug!("replayed durable MCP call result"); + } + Ok(response.result) +} + +async fn execute_virtual( + state: &McpState, + actor: &ToolActor, + correlation: &str, + arguments: ExecuteArguments, +) -> Result { + let timeout_ms = arguments + .timeout_ms + .unwrap_or(DEFAULT_EXECUTION_TIMEOUT_MILLIS); + if !(1..=MAX_EXECUTION_TIMEOUT_MILLIS).contains(&timeout_ms) { + return Err(VirtualToolError::InvalidArguments); + } + let output = state + .execution + .execute(ExecuteCodeRequest { + request_id: format!("mcp-{correlation}"), + actor: actor.clone(), + surface: RequestSurface::Mcp, + code: arguments.code, + timeout: Duration::from_millis(timeout_ms), + }) + .await + .map_err(VirtualToolError::Execution)?; + Ok(success_result(json!({ + "executionId": output.execution_id, + "result": output.result, + "emits": output.emits, + "console": output.console, + "calls": output.calls + }))) +} + +fn prepare_execute(arguments: Value) -> Result { + let arguments = serde_json::from_value::(arguments) + .map_err(|_| VirtualToolError::InvalidArguments)?; + let timeout_ms = arguments + .timeout_ms + .unwrap_or(DEFAULT_EXECUTION_TIMEOUT_MILLIS); + if arguments.code.len() > crate::runtime::MAX_SOURCE_BYTES + || !(1..=MAX_EXECUTION_TIMEOUT_MILLIS).contains(&timeout_ms) + { + return Err(VirtualToolError::InvalidArguments); + } + Ok(arguments) +} + +async fn search_virtual( + state: &McpState, + actor: &ToolActor, + execution_id: &str, + call_id: &str, + arguments: SearchArguments, +) -> Result { + let call = discovery_call( + actor, + execution_id, + call_id, + "executor.search", + serde_json::to_value(&arguments).expect("search arguments serialize"), + ); + let result = match state + .tool_calls + .discover_search( + &call, + arguments.query, + arguments.namespace, + arguments.limit, + arguments.offset, + ) + .await + { + Ok(result) => result, + Err(error) => { + tracing::warn!(error = %error, "MCP tool search failed"); + return Ok(failure_result("discovery_failed", "Tool search failed.")); + } + }; + Ok(success_result( + serde_json::to_value(result).expect("discovery result serializes"), + )) +} + +fn prepare_search(arguments: Value) -> Result { + let arguments = serde_json::from_value::(arguments) + .map_err(|_| VirtualToolError::InvalidArguments)?; + if !(1..=100).contains(&arguments.limit) { + return Err(VirtualToolError::InvalidArguments); + } + Ok(arguments) +} + +async fn describe_virtual( + state: &McpState, + actor: &ToolActor, + execution_id: &str, + call_id: &str, + arguments: DescribeArguments, +) -> Result { + let call = discovery_call( + actor, + execution_id, + call_id, + "executor.describe", + json!({ "path": arguments.path }), + ); + let result = match state + .tool_calls + .discover_describe(&call, &arguments.path) + .await + { + Ok(result) => result, + Err(error) => { + tracing::warn!(error = %error, "MCP tool description failed"); + return Ok(failure_result( + "discovery_failed", + "Tool description failed.", + )); + } + }; + Ok(success_result( + serde_json::to_value(result).expect("description serializes"), + )) +} + +fn prepare_describe(arguments: Value) -> Result { + let arguments = serde_json::from_value::(arguments) + .map_err(|_| VirtualToolError::InvalidArguments)?; + if arguments.path.is_empty() || arguments.path.len() > 512 { + return Err(VirtualToolError::InvalidArguments); + } + Ok(arguments) +} + +async fn sources_virtual( + state: &McpState, + actor: &ToolActor, + execution_id: &str, + call_id: &str, +) -> Result { + let call = discovery_call(actor, execution_id, call_id, "executor.sources", json!({})); + let result = match state.tool_calls.discover_sources(&call).await { + Ok(result) => result, + Err(error) => { + tracing::warn!(error = %error, "MCP source discovery failed"); + return Ok(failure_result( + "discovery_failed", + "Source discovery failed.", + )); + } + }; + Ok(success_result(json!({ "sources": result }))) +} + +fn prepare_sources(arguments: Value) -> Result<(), VirtualToolError> { + if arguments + .as_object() + .is_none_or(|arguments| !arguments.is_empty()) + { + return Err(VirtualToolError::InvalidArguments); + } + Ok(()) +} + +fn discovery_call( + actor: &ToolActor, + execution_id: &str, + call_id: &str, + path: &str, + arguments: Value, +) -> ToolCall { + ToolCall { + request_id: Uuid::new_v4().to_string(), + actor: actor.clone(), + surface: RequestSurface::Mcp, + execution_id: execution_id.to_owned(), + call_id: call_id.to_owned(), + worker_generation: 0, + path: path.to_owned(), + arguments, + } +} + +fn success_result(data: Value) -> ToolResult { + ToolResult { + ok: true, + data: Some(data), + error: None, + http: None, + } +} + +fn failure_result(code: &str, message: &str) -> ToolResult { + ToolResult { + ok: false, + data: None, + error: Some(crate::invocation::PublicToolError { + code: code.to_owned(), + message: message.to_owned(), + }), + http: None, + } +} + +fn virtual_tools() -> Vec { + vec![ + McpTool { + name: "execute".to_owned(), + title: "Execute TypeScript".to_owned(), + description: Some( + "Run sandboxed TypeScript that can discover and call multiple Executor tools concurrently." + .to_owned(), + ), + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["code"], + "properties": { + "code": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": MAX_EXECUTION_TIMEOUT_MILLIS } + } + }), + output_schema: Some(generic_output_schema()), + }, + McpTool { + name: "call".to_owned(), + title: "Call Tool".to_owned(), + description: Some( + "Call one enabled Executor tool. Ask-mode tools wait for administrator approval." + .to_owned(), + ), + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { "type": "string" }, + "arguments": { "type": "object", "additionalProperties": true } + } + }), + output_schema: None, + }, + McpTool { + name: "search".to_owned(), + title: "Search Tools".to_owned(), + description: Some("Search the enabled global Executor tool catalog.".to_owned()), + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["query"], + "properties": { + "query": { "type": "string" }, + "namespace": { "type": "string" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, + "offset": { "type": "integer", "minimum": 0, "default": 0 } + } + }), + output_schema: Some(generic_output_schema()), + }, + McpTool { + name: "describe".to_owned(), + title: "Describe Tool".to_owned(), + description: Some("Read the schemas and metadata for one enabled Executor tool.".to_owned()), + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { "path": { "type": "string" } } + }), + output_schema: Some(generic_output_schema()), + }, + McpTool { + name: "sources".to_owned(), + title: "List Sources".to_owned(), + description: Some("List sources with tools visible through the global catalog.".to_owned()), + input_schema: json!({ "type": "object", "additionalProperties": false }), + output_schema: Some(json!({ + "type": "object", + "additionalProperties": false, + "required": ["sources"], + "properties": { + "sources": { "type": "array", "items": { "type": "object" } } + } + })), + }, + ] +} + +fn generic_output_schema() -> Value { + json!({ "type": "object", "additionalProperties": true }) +} + +fn tool_result_response(id: Option, result: ToolResult, state: &McpState) -> Response { + let serialized = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_owned()); + let structured = result + .data + .as_ref() + .and_then(Value::as_object) + .map(|object| Value::Object(object.clone())); + let mut response = Map::from_iter([ + ( + "content".to_owned(), + json!([{ "type": "text", "text": serialized }]), + ), + ("isError".to_owned(), Value::Bool(!result.ok)), + ]); + if let Some(structured) = structured { + response.insert("structuredContent".to_owned(), structured); + } + rpc_result_response(id, Value::Object(response), None, state) +} + +fn idempotency_error(id: Option, error: GatewayInvokeError, state: &McpState) -> Response { + let (code, message) = match error { + GatewayInvokeError::ToolCall(error) => return tool_call_failure(id, error, state), + GatewayInvokeError::KeyMismatch => (-32600, "The JSON-RPC request ID was reused."), + GatewayInvokeError::OutcomeUnknown => (-32603, "The prior invocation outcome is unknown."), + GatewayInvokeError::InProgress => (-32603, "The invocation is still in progress."), + GatewayInvokeError::Canceled => (-32603, "The invocation wait was canceled."), + GatewayInvokeError::InvalidKey + | GatewayInvokeError::Capacity + | GatewayInvokeError::Idempotency(_) => { + tracing::warn!(error = %error, "MCP idempotent invocation failed"); + (-32603, "The invocation could not be completed safely.") + } + }; + rpc_error_response(id, code, message, None, state) +} + +fn tool_call_failure(id: Option, error: ToolCallError, state: &McpState) -> Response { + match error { + ToolCallError::Catalog(CatalogError::ToolNotFound { .. }) + | ToolCallError::Catalog(CatalogError::NotFound { .. }) + | ToolCallError::Catalog(CatalogError::ToolDisabled { .. }) + | ToolCallError::InvalidArguments + | ToolCallError::ArgumentsTooLarge => rpc_error_response( + id, + -32602, + "The tool or its arguments are invalid.", + None, + state, + ), + error => { + tracing::warn!(error = %error, "MCP tool invocation failed"); + tool_result_response( + id, + failure_result( + "tool_call_failed", + "The tool call could not be completed safely.", + ), + state, + ) + } + } +} + +fn rpc_result_response( + id: Option, + result: Value, + session_id: Option<&str>, + state: &McpState, +) -> Response { + json_response( + StatusCode::OK, + json!({ "jsonrpc": "2.0", "id": id.unwrap_or(Value::Null), "result": result }), + session_id, + state, + ) +} + +fn rpc_error_response( + id: Option, + code: i64, + message: &'static str, + data: Option, + state: &McpState, +) -> Response { + json_response( + StatusCode::OK, + json!({ + "jsonrpc": "2.0", + "id": id.unwrap_or(Value::Null), + "error": { "code": code, "message": message, "data": data } + }), + None, + state, + ) +} + +fn json_response( + status: StatusCode, + body: Value, + session_id: Option<&str>, + state: &McpState, +) -> Response { + let mut response = (status, axum::Json(body)).into_response(); + if let Some(session_id) = session_id { + response.headers_mut().insert( + SESSION_HEADER, + HeaderValue::from_str(session_id).expect("UUID session IDs are valid headers"), + ); + } + add_cors_headers(response.headers_mut(), state); + response +} + +#[derive(Debug)] +struct TransportError { + status: StatusCode, + code: &'static str, + message: &'static str, + authenticate: bool, +} + +impl TransportError { + fn bad_request(code: &'static str, message: &'static str) -> Self { + Self::new(StatusCode::BAD_REQUEST, code, message) + } + + fn unauthorized(code: &'static str, message: &'static str) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + code, + message, + authenticate: true, + } + } + + fn forbidden(code: &'static str, message: &'static str) -> Self { + Self::new(StatusCode::FORBIDDEN, code, message) + } + + fn not_found(code: &'static str, message: &'static str) -> Self { + Self::new(StatusCode::NOT_FOUND, code, message) + } + + fn not_acceptable(code: &'static str, message: &'static str) -> Self { + Self::new(StatusCode::NOT_ACCEPTABLE, code, message) + } + + fn service_unavailable(code: &'static str, message: &'static str) -> Self { + Self::new(StatusCode::SERVICE_UNAVAILABLE, code, message) + } + + fn too_many_requests(code: &'static str, message: &'static str) -> Self { + Self::new(StatusCode::TOO_MANY_REQUESTS, code, message) + } + + fn new(status: StatusCode, code: &'static str, message: &'static str) -> Self { + Self { + status, + code, + message, + authenticate: false, + } + } +} + +impl IntoResponse for TransportError { + fn into_response(self) -> Response { + let mut response = ( + self.status, + axum::Json(json!({ "error": { "code": self.code, "message": self.message } })), + ) + .into_response(); + if self.authenticate { + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static("Bearer realm=\"executor-mcp\""), + ); + } + response + } +} + +async fn authenticate(state: &McpState, headers: &HeaderMap) -> Result { + let authorization = + unique_text_header(headers, header::AUTHORIZATION.as_str())?.ok_or_else(|| { + TransportError::unauthorized("unauthorized", "A valid Executor API token is required.") + })?; + let mut parts = authorization.split_ascii_whitespace(); + let scheme = parts.next(); + let token = parts.next(); + if !scheme.is_some_and(|scheme| scheme.eq_ignore_ascii_case("bearer")) + || token.is_none() + || parts.next().is_some() + { + return Err(TransportError::unauthorized( + "unauthorized", + "A valid Executor API token is required.", + )); + } + let token = token.unwrap_or_default(); + if token.is_empty() || token.len() > MAX_BEARER_TOKEN_BYTES { + return Err(TransportError::unauthorized( + "unauthorized", + "A valid Executor API token is required.", + )); + } + let digest = state.database.keyring.digest("api-token", token.as_bytes()); + let identity = sqlx::query_as::<_, (String, String, Option)>( + "SELECT id, name, last_used_at FROM api_tokens WHERE token_digest = ? AND revoked_at IS NULL", + ) + .bind(digest.to_vec()) + .fetch_optional(&state.database.pool) + .await + .map_err(|error| { + tracing::error!(error = %error, "MCP token authentication failed"); + TransportError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "The request could not be authenticated.", + ) + })? + .ok_or_else(|| { + TransportError::unauthorized( + "unauthorized", + "A valid Executor API token is required.", + ) + })?; + let now = unix_timestamp(); + if identity + .2 + .is_none_or(|last_used_at| last_used_at <= now - 60) + { + let _ = sqlx::query( + "UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND revoked_at IS NULL AND (last_used_at IS NULL OR last_used_at <= ?)", + ) + .bind(now) + .bind(&identity.0) + .bind(now - 60) + .execute(&state.database.pool) + .await; + } + Ok(Identity { + token_id: identity.0, + token_name: identity.1, + }) +} + +fn validate_origin(state: &McpState, headers: &HeaderMap) -> Result<(), TransportError> { + let Some(origin) = unique_text_header(headers, header::ORIGIN.as_str())? else { + return Ok(()); + }; + if origin == state.origin.as_ref() { + Ok(()) + } else { + Err(TransportError::forbidden( + "invalid_origin", + "The request Origin does not match this Executor instance.", + )) + } +} + +fn validate_connection(state: &McpState, headers: &HeaderMap) -> Result<(), TransportError> { + validate_host(state, headers)?; + validate_origin(state, headers) +} + +fn validate_host(state: &McpState, headers: &HeaderMap) -> Result<(), TransportError> { + let host = unique_text_header(headers, header::HOST.as_str())?.ok_or_else(|| { + TransportError::bad_request("missing_host", "Host is required for the MCP endpoint.") + })?; + if host.len() > 512 || host.contains(['/', '\\', '@', '#', '?']) { + return Err(TransportError::forbidden( + "invalid_host", + "Host does not match this Executor instance.", + )); + } + let candidate = + Url::parse(&format!("{}://{host}", state.origin_url.scheme())).map_err(|_| { + TransportError::forbidden( + "invalid_host", + "Host does not match this Executor instance.", + ) + })?; + let matches = candidate.host() == state.origin_url.host() + && candidate.port_or_known_default() == state.origin_url.port_or_known_default(); + if matches { + Ok(()) + } else { + Err(TransportError::forbidden( + "invalid_host", + "Host does not match this Executor instance.", + )) + } +} + +fn validate_post_headers(headers: &HeaderMap) -> Result<(), TransportError> { + let content_type = + unique_text_header(headers, header::CONTENT_TYPE.as_str())?.ok_or_else(|| { + TransportError::new( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported_content_type", + "POST /mcp requires Content-Type: application/json.", + ) + })?; + let mut content_type_parts = content_type.split(';'); + let media_type_matches = content_type_parts + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json")); + let parameters_valid = content_type_parts.all(|parameter| { + parameter + .trim() + .split_once('=') + .is_some_and(|(name, value)| { + name.trim().eq_ignore_ascii_case("charset") + && value.trim().trim_matches('"').eq_ignore_ascii_case("utf-8") + }) + }); + if !media_type_matches || !parameters_valid { + return Err(TransportError::new( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported_content_type", + "POST /mcp requires Content-Type: application/json.", + )); + } + if !accepts(headers, "application/json") || !accepts(headers, "text/event-stream") { + return Err(TransportError::not_acceptable( + "unsupported_accept", + "POST /mcp requires Accept for application/json and text/event-stream.", + )); + } + Ok(()) +} + +fn require_protocol_version(headers: &HeaderMap) -> Result<(), TransportError> { + let version = unique_text_header(headers, PROTOCOL_HEADER)?.ok_or_else(|| { + TransportError::bad_request( + "missing_protocol_version", + "MCP-Protocol-Version is required after initialization.", + ) + })?; + if version == PROTOCOL_VERSION { + Ok(()) + } else { + Err(TransportError::bad_request( + "unsupported_protocol_version", + "MCP-Protocol-Version is not supported.", + )) + } +} + +fn unique_text_header<'a>( + headers: &'a HeaderMap, + name: &str, +) -> Result, TransportError> { + let values = headers.get_all(name); + let mut values = values.iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(TransportError::bad_request( + "duplicate_header", + "A security-sensitive header was repeated.", + )); + } + value.to_str().map(Some).map_err(|_| { + TransportError::bad_request("invalid_header", "A request header is not valid text.") + }) +} + +fn accepts(headers: &HeaderMap, expected: &str) -> bool { + headers + .get_all(header::ACCEPT) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|item| { + let mut segments = item.split(';'); + let media_type = segments.next().unwrap_or_default().trim(); + let rejected = segments.any(|parameter| { + parameter + .trim() + .split_once('=') + .is_some_and(|(name, value)| { + name.trim().eq_ignore_ascii_case("q") + && value + .trim() + .parse::() + .is_ok_and(|quality| quality <= 0.0) + }) + }); + !rejected && (media_type == "*/*" || media_type.eq_ignore_ascii_case(expected)) + }) +} + +fn add_cors_headers(headers: &mut HeaderMap, state: &McpState) { + if let Ok(origin) = HeaderValue::from_str(&state.origin) { + headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + } + headers.insert(header::VARY, HeaderValue::from_static("Origin")); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("POST, GET, DELETE, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static( + "Authorization, Content-Type, Accept, MCP-Session-Id, MCP-Protocol-Version", + ), + ); + headers.insert( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static("MCP-Session-Id"), + ); +} + +fn decode_optional_params(value: Value) -> Result +where + T: for<'de> Deserialize<'de> + Default, +{ + if value.is_null() { + Ok(T::default()) + } else { + serde_json::from_value(value).map_err(|_| ()) + } +} + +fn empty_arguments() -> Map { + Map::new() +} + +fn default_search_limit() -> u32 { + 20 +} + +fn rpc_id_string(id: &Value) -> String { + match id { + Value::String(value) => format!("s:{value}"), + Value::Number(value) => format!("n:{value}"), + _ => "invalid".to_owned(), + } +} + +fn mcp_idempotency_key(session_id: &str, request_id: &str) -> String { + let mut digest = Sha256::new(); + digest.update(b"executor-mcp-idempotency-v1"); + digest.update((session_id.len() as u64).to_be_bytes()); + digest.update(session_id.as_bytes()); + digest.update((request_id.len() as u64).to_be_bytes()); + digest.update(request_id.as_bytes()); + format!("mcp_{:x}", digest.finalize()) +} + +fn request_cancellation_key(session_id: &str, request_id: &str) -> String { + format!("{session_id}\0{request_id}") +} + +fn cancellation_signal(registration: &CancellationRegistration) -> CancellationSignal { + CancellationSignal { + notify: registration.notify.clone(), + canceled: registration.canceled.clone(), + } +} + +impl CancellationSignal { + async fn wait(&self) { + if self.canceled.load(Ordering::Acquire) { + return; + } + let notified = self.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.canceled.load(Ordering::Acquire) { + return; + } + notified.await; + } +} + +fn valid_request_id(id: &Value) -> bool { + match id { + Value::String(value) => value.len() <= 256 && !value.contains('\0'), + Value::Number(_) => true, + _ => false, + } +} + +fn valid_jsonrpc_error(error: &Value) -> bool { + error.as_object().is_some_and(|error| { + error.get("code").and_then(Value::as_i64).is_some() + && error.get("message").and_then(Value::as_str).is_some() + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/mcp/downstream/tests.rs b/src/mcp/downstream/tests.rs new file mode 100644 index 000000000..9bfd33746 --- /dev/null +++ b/src/mcp/downstream/tests.rs @@ -0,0 +1,1210 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use axum::{ + Router, + body::Body, + http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use super::{ + CancellationRegistry, McpRequestLimiter, PROTOCOL_HEADER, PROTOCOL_VERSION, SESSION_HEADER, + SessionRegistrationRaceHook, accepts, cancellation_signal, mcp_idempotency_key, rpc_id_string, + valid_request_id, virtual_tools, +}; +use crate::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, ToolMode, + }, + invocation::{McpIdempotencyClaim, McpIdempotencyRequest}, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const PASSWORD: &str = "correct-horse-battery-staple"; + +#[test] +fn accept_parsing_requires_each_supported_transport_type() { + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCEPT, + HeaderValue::from_static("application/json, text/event-stream"), + ); + assert!(accepts(&headers, "application/json")); + assert!(accepts(&headers, "text/event-stream")); + assert!(!accepts(&headers, "image/png")); + + headers.insert( + header::ACCEPT, + HeaderValue::from_static("application/json;q=0, text/event-stream"), + ); + assert!(!accepts(&headers, "application/json")); +} + +#[test] +fn advertised_protocol_is_the_stable_revision() { + assert_eq!(PROTOCOL_VERSION, "2025-11-25"); +} + +#[test] +fn only_five_stable_virtual_tools_are_advertised() { + let tools = virtual_tools(); + assert_eq!( + tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + ["execute", "call", "search", "describe", "sources"] + ); + let sources = tools + .iter() + .find(|tool| tool.name == "sources") + .expect("sources tool"); + assert_eq!( + sources.output_schema.as_ref().expect("output schema"), + &json!({ + "type": "object", + "additionalProperties": false, + "required": ["sources"], + "properties": { + "sources": { "type": "array", "items": { "type": "object" } } + } + }) + ); +} + +#[test] +fn request_ids_are_bounded_strings_or_numbers() { + assert!(valid_request_id(&serde_json::json!(42))); + assert!(valid_request_id(&serde_json::json!("request"))); + assert!(!valid_request_id(&serde_json::Value::Null)); + assert!(!valid_request_id(&serde_json::json!(true))); + assert!(!valid_request_id(&serde_json::json!("x".repeat(257)))); + assert_ne!( + mcp_idempotency_key("session", &rpc_id_string(&json!(1))), + mcp_idempotency_key("session", &rpc_id_string(&json!("1"))) + ); +} + +#[tokio::test] +async fn cancellation_arriving_before_call_registration_is_consumed() { + let registry = CancellationRegistry::default(); + registry.cancel("session\0s:request"); + let registration = registry + .register("session\0s:request".to_owned()) + .expect("pre-canceled request can register"); + tokio::time::timeout( + std::time::Duration::from_millis(50), + cancellation_signal(®istration).wait(), + ) + .await + .expect("pre-cancel signal resolves before side effects"); +} + +#[test] +fn request_limiter_is_per_token_and_releases_with_detached_work() { + let limiter = McpRequestLimiter::new(16, 4); + let permits = (0..4) + .map(|_| limiter.try_acquire("token-a").expect("token slot")) + .collect::>(); + assert!(limiter.try_acquire("token-a").is_none()); + let other = limiter + .try_acquire("token-b") + .expect("another token keeps an independent allowance"); + drop(permits); + assert!(limiter.try_acquire("token-a").is_some()); + drop(other); +} + +#[tokio::test] +async fn saturated_execution_slots_do_not_block_cancellation_control() { + let execution_limiter = McpRequestLimiter::new(16, 4); + let _executions = (0..4) + .map(|_| { + execution_limiter + .try_acquire("token-a") + .expect("execution permit") + }) + .collect::>(); + assert!(execution_limiter.try_acquire("token-a").is_none()); + + let registry = CancellationRegistry::default(); + let registration = registry + .register("session\0s:request".to_owned()) + .expect("control registration"); + registry.cancel("session\0s:request"); + tokio::time::timeout( + std::time::Duration::from_millis(50), + cancellation_signal(®istration).wait(), + ) + .await + .expect("cancellation remains available under execution saturation"); +} + +#[tokio::test] +async fn expired_session_cancels_pending_ask_before_it_can_execute() { + let directory = tempfile::tempdir().expect("temporary directory"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + configure_ask_tool(&app).await; + let router = app.router(); + let setup = send_json( + router.clone(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("setup token"), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let login = send_json( + router.clone(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + let cookie = response_cookies(&login); + let login_body = response_json(login).await; + let csrf = login_body["csrfToken"].as_str().expect("CSRF token"); + let token = response_json( + send_json( + router.clone(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Expiring MCP" }), + &[ + (header::COOKIE.as_str(), &cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", csrf), + ], + ) + .await, + ) + .await["token"] + .as_str() + .expect("API token") + .to_owned(); + let authorization = format!("Bearer {token}"); + let transport_headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), authorization.as_str()), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + ]; + let initialized = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": "expiry-initialize", + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "expiry-test", "version": "1" } + } + }), + &transport_headers, + ) + .await; + let session = initialized + .headers() + .get(SESSION_HEADER) + .expect("session header") + .to_str() + .expect("session text") + .to_owned(); + let session_headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), authorization.as_str()), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, session.as_str()), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ]; + assert_eq!( + send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + &session_headers, + ) + .await + .status(), + StatusCode::ACCEPTED + ); + let pending_ask = spawn_mcp_call( + router.clone(), + authorization.clone(), + session.clone(), + "expiry-ask", + "call", + json!({ + "path": "expiry_approval.write", + "arguments": { "value": "must not execute" } + }), + ); + let approval_id = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if let Some(approval_id) = sqlx::query_scalar::<_, String>( + "SELECT id FROM approvals WHERE status = 'pending' ORDER BY sequence DESC LIMIT 1", + ) + .fetch_optional(app.pool()) + .await + .expect("approval query") + { + break approval_id; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("Ask approval should become pending"); + { + let mut sessions = app + .mcp_state() + .sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sessions + .get_mut(&session) + .expect("active session") + .last_seen = + std::time::Instant::now() - super::SESSION_TTL - std::time::Duration::from_secs(1); + } + let expired = send_json( + router, + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": "expiry-trigger", "method": "ping" }), + &session_headers, + ) + .await; + assert_eq!(expired.status(), StatusCode::NOT_FOUND); + let canceled = tokio::time::timeout(std::time::Duration::from_secs(2), pending_ask) + .await + .expect("expiry should release the pending Ask") + .expect("request task"); + assert_eq!(response_json(canceled).await["error"]["code"], -32603); + let approval_status = + sqlx::query_scalar::<_, String>("SELECT status FROM approvals WHERE id = ?") + .bind(approval_id) + .fetch_one(app.pool()) + .await + .expect("approval status"); + assert_eq!(approval_status, "canceled"); + app.shutdown().await; +} + +#[tokio::test] +async fn streamable_http_lifecycle_is_authenticated_session_bound_and_finite() { + let directory = tempfile::tempdir().expect("temporary directory"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let router = app.router(); + let unauthenticated_oversized = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "padding": "x".repeat(9 * 1024 * 1024) }), + &[ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + ], + ) + .await; + assert_eq!(unauthenticated_oversized.status(), StatusCode::UNAUTHORIZED); + let invalid_origin_before_auth = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({}), + &[ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), "https://attacker.invalid"), + ], + ) + .await; + assert_eq!(invalid_origin_before_auth.status(), StatusCode::FORBIDDEN); + let setup = send_json( + router.clone(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("setup token"), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let login = send_json( + router.clone(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + let cookie = response_cookies(&login); + let login_body = response_json(login).await; + let csrf = login_body["csrfToken"].as_str().expect("CSRF token"); + let token_response = send_json( + router.clone(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "MCP" }), + &[ + (header::COOKIE.as_str(), &cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", csrf), + ], + ) + .await; + let token_body = response_json(token_response).await; + let token_id = token_body["id"].as_str().expect("token ID").to_owned(); + let token = token_body["token"].as_str().expect("API token").to_owned(); + let authorization = format!("Bearer {token}"); + let transport_headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), authorization.as_str()), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + ]; + + let wrong_host = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} }), + &[(header::HOST.as_str(), "attacker.invalid")], + ) + .await; + assert_eq!(wrong_host.status(), StatusCode::FORBIDDEN); + + let initialized = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "test", "version": "1" } + } + }), + &transport_headers, + ) + .await; + assert_eq!(initialized.status(), StatusCode::OK); + let session = initialized + .headers() + .get(SESSION_HEADER) + .expect("session header") + .to_str() + .expect("session text") + .to_owned(); + let body = response_json(initialized).await; + assert_eq!(body["result"]["protocolVersion"], PROTOCOL_VERSION); + + let session_headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), authorization.as_str()), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, session.as_str()), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ]; + let notification = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + &session_headers, + ) + .await; + assert_eq!(notification.status(), StatusCode::ACCEPTED); + let client_response = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": "server-request", "result": {} }), + &session_headers, + ) + .await; + assert_eq!(client_response.status(), StatusCode::ACCEPTED); + let null_response = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": "server-null", "result": null }), + &session_headers, + ) + .await; + assert_eq!(null_response.status(), StatusCode::ACCEPTED); + for invalid_envelope in [ + json!({ "jsonrpc": "2.0", "id": 20, "method": "ping", "result": {} }), + json!({ "jsonrpc": "2.0", "id": 21, "result": {}, "error": { "code": -1, "message": "bad" } }), + json!({ "jsonrpc": "2.0", "id": 22, "error": { "message": "missing code" } }), + json!({ "jsonrpc": "2.0", "id": 23, "result": {}, "params": {} }), + ] { + let invalid = send_json( + router.clone(), + Method::POST, + "/mcp", + invalid_envelope, + &session_headers, + ) + .await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + } + let missing_protocol = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": "missing-version", "method": "ping" }), + &session_headers[..5], + ) + .await; + assert_eq!(missing_protocol.status(), StatusCode::BAD_REQUEST); + let tools = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": "tools", "method": "tools/list" }), + &session_headers, + ) + .await; + let body = response_json(tools).await; + assert_eq!(body["result"]["tools"].as_array().map(Vec::len), Some(5)); + + for (request_id, name, invalid_arguments, corrected_arguments) in [ + ( + "invalid-search", + "search", + json!({ "query": "tool", "limit": 0 }), + json!({ "query": "tool" }), + ), + ( + "invalid-describe", + "describe", + json!({ "path": "" }), + json!({ "path": "missing.tool" }), + ), + ( + "invalid-sources", + "sources", + json!({ "unexpected": true }), + json!({}), + ), + ] { + let invalid_call = json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { "name": name, "arguments": invalid_arguments } + }); + for _ in 0..2 { + let invalid = response_json( + send_json( + router.clone(), + Method::POST, + "/mcp", + invalid_call.clone(), + &session_headers, + ) + .await, + ) + .await; + assert_eq!(invalid["error"]["code"], -32602); + } + let corrected = response_json( + send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { "name": name, "arguments": corrected_arguments } + }), + &session_headers, + ) + .await, + ) + .await; + assert!(corrected.get("result").is_some()); + } + + let sources_call = json!({ + "jsonrpc": "2.0", + "id": "stable-call", + "method": "tools/call", + "params": { "name": "sources", "arguments": {} } + }); + let first_sources = response_json( + send_json( + router.clone(), + Method::POST, + "/mcp", + sources_call.clone(), + &session_headers, + ) + .await, + ) + .await; + assert!(first_sources["result"]["structuredContent"]["sources"].is_array()); + let replayed_sources = response_json( + send_json( + router.clone(), + Method::POST, + "/mcp", + sources_call, + &session_headers, + ) + .await, + ) + .await; + assert_eq!(replayed_sources, first_sources); + let mismatched = response_json( + send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": "stable-call", + "method": "tools/call", + "params": { "name": "search", "arguments": { "query": "tool" } } + }), + &session_headers, + ) + .await, + ) + .await; + assert_eq!(mismatched["error"]["code"], -32600); + + let get = send_empty( + router.clone(), + Method::GET, + "/mcp", + &[ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), authorization.as_str()), + (header::ACCEPT.as_str(), "text/event-stream"), + (SESSION_HEADER, session.as_str()), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ], + ) + .await; + assert_eq!(get.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + get.headers().get(header::ALLOW), + Some(&HeaderValue::from_static("POST, DELETE, OPTIONS")) + ); + + let idempotency_rows_before_delete_race = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM gateway_invocation_idempotency") + .fetch_one(app.pool()) + .await + .expect("idempotency count"); + let delete_hook = Arc::new(SessionRegistrationRaceHook::default()); + *app.mcp_state() + .session_registration_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(delete_hook.clone()); + let pending_delete_race = spawn_mcp_call( + router.clone(), + authorization.clone(), + session.clone(), + "delete-race", + "sources", + json!({}), + ); + tokio::time::timeout( + std::time::Duration::from_secs(1), + delete_hook.validation_complete.notified(), + ) + .await + .expect("tools/call should pause after session validation"); + let deleted = send_empty(router.clone(), Method::DELETE, "/mcp", &session_headers).await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + delete_hook.continue_registration.notify_one(); + let delete_race_response = + tokio::time::timeout(std::time::Duration::from_secs(1), pending_delete_race) + .await + .expect("deleted session request should stop") + .expect("request task"); + assert_eq!( + response_json(delete_race_response).await["error"]["code"], + -32603 + ); + *app.mcp_state() + .session_registration_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let idempotency_rows_after_delete_race = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM gateway_invocation_idempotency") + .fetch_one(app.pool()) + .await + .expect("idempotency count"); + assert_eq!( + idempotency_rows_after_delete_race, + idempotency_rows_before_delete_race + ); + let after_delete = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": 2, "method": "ping" }), + &session_headers, + ) + .await; + assert_eq!(after_delete.status(), StatusCode::NOT_FOUND); + + let second_token = response_json( + send_json( + router.clone(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Other MCP" }), + &[ + (header::COOKIE.as_str(), &cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", csrf), + ], + ) + .await, + ) + .await["token"] + .as_str() + .expect("second token") + .to_owned(); + let new_session_response = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "test", "version": "1" } + } + }), + &transport_headers, + ) + .await; + let new_session = new_session_response + .headers() + .get(SESSION_HEADER) + .expect("new session") + .to_str() + .expect("session text") + .to_owned(); + let new_session_headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), authorization.as_str()), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, new_session.as_str()), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ]; + assert_eq!( + send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + &new_session_headers, + ) + .await + .status(), + StatusCode::ACCEPTED + ); + let other_authorization = format!("Bearer {second_token}"); + let cross_token = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": 10, "method": "ping" }), + &[ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), &other_authorization), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, &new_session), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ], + ) + .await; + assert_eq!(cross_token.status(), StatusCode::NOT_FOUND); + for request_id in 100..131 { + let extra_session = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "capacity-test", "version": "1" } + } + }), + &transport_headers, + ) + .await; + assert_eq!(extra_session.status(), StatusCode::OK); + } + let token_capacity = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": 131, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "capacity-test", "version": "1" } + } + }), + &transport_headers, + ) + .await; + assert_eq!(token_capacity.status(), StatusCode::SERVICE_UNAVAILABLE); + let idempotency_rows_before_revoke_race = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM gateway_invocation_idempotency") + .fetch_one(app.pool()) + .await + .expect("idempotency count"); + let revoke_hook = Arc::new(SessionRegistrationRaceHook::default()); + *app.mcp_state() + .session_registration_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(revoke_hook.clone()); + let pending_revoke_race = spawn_mcp_call( + router.clone(), + authorization.clone(), + new_session.clone(), + "revoke-race", + "sources", + json!({}), + ); + tokio::time::timeout( + std::time::Duration::from_secs(1), + revoke_hook.validation_complete.notified(), + ) + .await + .expect("tools/call should pause after session validation"); + let revoked = send_empty( + router.clone(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + &[ + (header::COOKIE.as_str(), &cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", csrf), + ], + ) + .await; + assert_eq!(revoked.status(), StatusCode::NO_CONTENT); + revoke_hook.continue_registration.notify_one(); + let revoke_race_response = + tokio::time::timeout(std::time::Duration::from_secs(1), pending_revoke_race) + .await + .expect("revoked token request should stop") + .expect("request task"); + assert_eq!( + response_json(revoke_race_response).await["error"]["code"], + -32603 + ); + *app.mcp_state() + .session_registration_race_hook + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let idempotency_rows_after_revoke_race = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM gateway_invocation_idempotency") + .fetch_one(app.pool()) + .await + .expect("idempotency count"); + assert_eq!( + idempotency_rows_after_revoke_race, + idempotency_rows_before_revoke_race + ); + let revoked_session = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "id": 11, "method": "ping" }), + &[ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), &authorization), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, &new_session), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ], + ) + .await; + assert_eq!(revoked_session.status(), StatusCode::UNAUTHORIZED); + + let preflight = send_empty( + router.clone(), + Method::OPTIONS, + "/mcp", + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(preflight.status(), StatusCode::NO_CONTENT); + + let shutdown_authorization = format!("Bearer {second_token}"); + let shutdown_transport_headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + ( + header::AUTHORIZATION.as_str(), + shutdown_authorization.as_str(), + ), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + ]; + let shutdown_initialized = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": "shutdown-initialize", + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "shutdown-test", "version": "1" } + } + }), + &shutdown_transport_headers, + ) + .await; + let shutdown_session = shutdown_initialized + .headers() + .get(SESSION_HEADER) + .expect("shutdown session") + .to_str() + .expect("session text") + .to_owned(); + let shutdown_session_headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + ( + header::AUTHORIZATION.as_str(), + shutdown_authorization.as_str(), + ), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, shutdown_session.as_str()), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ]; + let shutdown_notification = send_json( + router.clone(), + Method::POST, + "/mcp", + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + &shutdown_session_headers, + ) + .await; + assert_eq!(shutdown_notification.status(), StatusCode::ACCEPTED); + + let (second_token_id,): (String,) = + sqlx::query_as("SELECT id FROM api_tokens WHERE name = 'Other MCP'") + .fetch_one(app.pool()) + .await + .expect("second token ID"); + let shutdown_request_id = json!("shutdown-pending"); + let shutdown_correlation = rpc_id_string(&shutdown_request_id); + let shutdown_idempotency = McpIdempotencyRequest { + owner_api_token_id: second_token_id, + key: mcp_idempotency_key(&shutdown_session, &shutdown_correlation), + route: "POST /mcp tools/call".to_owned(), + callable_path: "executor.sources".to_owned(), + arguments: json!({}), + }; + let reservation = match app + .tool_calls() + .claim_mcp_idempotency(&shutdown_idempotency) + .await + .expect("idempotency claim") + { + McpIdempotencyClaim::Fresh(reservation) => *reservation, + _ => panic!("new shutdown request should own a fresh reservation"), + }; + let execution = reservation + .mark_executing() + .await + .expect("pending idempotency execution"); + let pending_authorization = shutdown_authorization.clone(); + let pending_session = shutdown_session.clone(); + let pending_request = tokio::spawn(async move { + let headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + ( + header::AUTHORIZATION.as_str(), + pending_authorization.as_str(), + ), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, pending_session.as_str()), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ]; + send_json( + router, + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": shutdown_request_id, + "method": "tools/call", + "params": { "name": "sources", "arguments": {} } + }), + &headers, + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!(!pending_request.is_finished()); + app.begin_shutdown(); + let shutdown_response = + tokio::time::timeout(std::time::Duration::from_secs(1), pending_request) + .await + .expect("pre-drain shutdown should release the pending handler") + .expect("request task"); + assert_eq!( + response_json(shutdown_response).await["error"]["code"], + -32603 + ); + execution + .mark_indeterminate() + .await + .expect("clean up synthetic execution"); + app.shutdown().await; +} + +fn spawn_mcp_call( + router: Router, + authorization: String, + session: String, + request_id: &'static str, + name: &'static str, + arguments: Value, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let headers = [ + (header::HOST.as_str(), "127.0.0.1:4788"), + (header::ORIGIN.as_str(), ORIGIN), + (header::AUTHORIZATION.as_str(), authorization.as_str()), + ( + header::ACCEPT.as_str(), + "application/json, text/event-stream", + ), + (SESSION_HEADER, session.as_str()), + (PROTOCOL_HEADER, PROTOCOL_VERSION), + ]; + send_json( + router, + Method::POST, + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }), + &headers, + ) + .await + }) +} + +async fn configure_ask_tool(app: &ExecutorApp) { + app.catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "expiry-approval".to_owned(), + display_name: "Expiry approval source".to_owned(), + description: None, + configuration: json!({ + "spec": { "type": "inline" }, + "allowPrivateNetwork": true + }) + .as_object() + .expect("source configuration object") + .clone(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { "schemes": {} } + }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "write".to_owned(), + preferred_name: "write".to_owned(), + display_name: "Write".to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { "value": { "type": "string" } } + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Ask, + }], + }, + vec![StagedToolBinding { + stable_key: "write".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "POST".to_owned(), + path_template: "/write".to_owned(), + server_url: "http://127.0.0.1:9".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }], + AuditContext::system(Some("mcp-expiry-test")), + ) + .await + .expect("Ask tool should import"); +} + +async fn send_json( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot(request.body(Body::from(body.to_string())).expect("request")) + .await + .expect("router response") +} + +async fn send_empty( + router: Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot(request.body(Body::empty()).expect("request")) + .await + .expect("router response") +} + +async fn response_json(response: Response) -> Value { + let body = response + .into_body() + .collect() + .await + .expect("response body") + .to_bytes(); + serde_json::from_slice(&body).expect("JSON response") +} + +fn response_cookies(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie text") + .split(';') + .next() + .expect("cookie pair") + }) + .collect::>() + .join("; ") +} diff --git a/src/mcp/manager.rs b/src/mcp/manager.rs new file mode 100644 index 000000000..247e21123 --- /dev/null +++ b/src/mcp/manager.rs @@ -0,0 +1,1194 @@ +use std::{ + collections::{HashMap, HashSet}, + future::Future, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use thiserror::Error; +use tokio::sync::Notify; + +use super::upstream::stdio::StdioTemplateRegistry; + +const SHUTDOWN_GRACE: Duration = Duration::from_secs(5); + +#[derive(Clone)] +pub(crate) struct McpConnectionManager { + inner: Arc, +} + +struct ManagerInner { + stdio_templates: Arc, + shutting_down: AtomicBool, + active_operations: AtomicUsize, + idle: Notify, + watcher_operations: tokio::sync::Mutex<()>, + lifecycle: Arc>, + watcher_state: Mutex, + next_watcher_generation: AtomicU64, +} + +#[derive(Default)] +struct WatcherState { + active: HashMap, + retired_sources: HashSet, + latest_source_revisions: HashMap, + pending: Vec, +} + +struct PendingWatcherTask { + source_id: String, + task: tokio::task::JoinHandle<()>, +} + +struct WatcherTask { + generation: u64, + source_revision: i64, + cancel: Option>, + task: tokio::task::JoinHandle<()>, +} + +pub(crate) struct McpOperationGuard { + inner: Arc, +} + +#[derive(Clone)] +pub(crate) struct WatcherRevisionLease { + inner: Arc, + source_id: String, + generation: u64, +} + +pub(crate) struct WatcherRevisionGuard { + inner: Arc, + source_id: String, + generation: u64, + expected_revision: i64, + _lifecycle: tokio::sync::OwnedMutexGuard<()>, +} + +#[derive(Debug, Error)] +#[error("MCP connections are shutting down")] +pub(crate) struct McpShuttingDown; + +impl McpConnectionManager { + pub(crate) fn new(stdio_templates: StdioTemplateRegistry) -> Self { + Self { + inner: Arc::new(ManagerInner { + stdio_templates: Arc::new(stdio_templates), + shutting_down: AtomicBool::new(false), + active_operations: AtomicUsize::new(0), + idle: Notify::new(), + watcher_operations: tokio::sync::Mutex::new(()), + lifecycle: Arc::new(tokio::sync::Mutex::new(())), + watcher_state: Mutex::new(WatcherState::default()), + next_watcher_generation: AtomicU64::new(1), + }), + } + } + + pub(crate) fn stdio_templates(&self) -> &Arc { + &self.inner.stdio_templates + } + + pub(crate) fn begin_operation(&self) -> Result { + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err(McpShuttingDown); + } + self.inner.active_operations.fetch_add(1, Ordering::AcqRel); + if self.inner.shutting_down.load(Ordering::Acquire) { + if self.inner.active_operations.fetch_sub(1, Ordering::AcqRel) == 1 { + self.inner.idle.notify_waiters(); + } + return Err(McpShuttingDown); + } + Ok(McpOperationGuard { + inner: self.inner.clone(), + }) + } + + pub(crate) fn begin_shutdown(&self) { + self.inner.shutting_down.store(true, Ordering::Release); + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + for watcher in state.active.values_mut() { + cancel_watcher(watcher); + } + prune_finished_tasks(&mut state.pending); + drop(state); + if self.inner.active_operations.load(Ordering::Acquire) == 0 { + self.inner.idle.notify_waiters(); + } + } + + /// Replaces a source watcher after its predecessor has completely stopped. + /// + /// `Ok(false)` means the source was retired or superseded while an install was prepared. + pub(crate) async fn replace_watcher( + &self, + source_id: String, + source_revision: i64, + factory: Factory, + ) -> Result + where + Factory: FnOnce(tokio::sync::oneshot::Receiver<()>, WatcherRevisionLease) -> Watcher + + Send + + 'static, + Watcher: Future + Send + 'static, + { + let _operation = self.inner.watcher_operations.lock().await; + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err(McpShuttingDown); + } + + let (previous, pending) = { + let _lifecycle = self.inner.lifecycle.lock().await; + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + prune_finished_tasks(&mut state.pending); + if state.retired_sources.contains(&source_id) { + return Ok(false); + } + if state + .latest_source_revisions + .get(&source_id) + .is_some_and(|latest| *latest > source_revision) + { + return Ok(false); + } + state + .latest_source_revisions + .insert(source_id.clone(), source_revision); + let previous = state.active.remove(&source_id); + let pending = take_pending_tasks(&mut state, &source_id); + (previous, pending) + }; + if let Some(previous) = previous { + stop_and_join_watcher(previous).await; + } + join_tasks(pending).await; + + let (cancel, canceled) = tokio::sync::oneshot::channel(); + let (start, mut started) = tokio::sync::oneshot::channel(); + let generation = self + .inner + .next_watcher_generation + .fetch_add(1, Ordering::Relaxed); + let inner = self.inner.clone(); + let cleanup_source_id = source_id.clone(); + let revision_lease = WatcherRevisionLease { + inner: self.inner.clone(), + source_id: source_id.clone(), + generation, + }; + let mut canceled = canceled; + let task = tokio::spawn(async move { + let _cleanup = WatcherCleanup { + inner: inner.clone(), + source_id: cleanup_source_id.clone(), + generation, + }; + tokio::select! { + start = &mut started => { + if start.is_ok() { + factory(canceled, revision_lease).await; + } + } + _ = &mut canceled => {} + } + }); + let mut replacement = Some(WatcherTask { + generation, + source_revision, + cancel: Some(cancel), + task, + }); + let installed = { + let _lifecycle = self.inner.lifecycle.lock().await; + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + prune_finished_tasks(&mut state.pending); + if self.inner.shutting_down.load(Ordering::Acquire) { + None + } else if state.retired_sources.contains(&source_id) + || state + .latest_source_revisions + .get(&source_id) + .is_some_and(|latest| *latest > source_revision) + { + Some(false) + } else { + state + .active + .insert(source_id, replacement.take().expect("replacement exists")); + Some(true) + } + }; + + match installed { + Some(true) => { + let _ = start.send(()); + Ok(true) + } + Some(false) => { + stop_and_join_watcher(replacement.expect("uninstalled replacement exists")).await; + Ok(false) + } + None => { + stop_and_join_watcher(replacement.expect("uninstalled replacement exists")).await; + Err(McpShuttingDown) + } + } + } + + pub(crate) async fn stop_watcher_at_revision( + &self, + source_id: &str, + source_revision: i64, + ) -> bool { + let _lifecycle = self.inner.lifecycle.lock().await; + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + prune_finished_tasks(&mut state.pending); + if state + .latest_source_revisions + .get(source_id) + .is_some_and(|latest| *latest > source_revision) + { + return false; + } + state + .latest_source_revisions + .insert(source_id.to_owned(), source_revision); + let watcher = state.active.remove(source_id); + if let Some(mut watcher) = watcher { + if watcher.source_revision > source_revision { + state.active.insert(source_id.to_owned(), watcher); + return false; + } + cancel_watcher(&mut watcher); + state.pending.push(PendingWatcherTask { + source_id: source_id.to_owned(), + task: watcher.task, + }); + } + true + } + + pub(crate) async fn stop_watcher_and_wait_at_revision( + &self, + source_id: &str, + source_revision: i64, + ) -> bool { + let _operation = self.inner.watcher_operations.lock().await; + let (watcher, pending) = { + let _lifecycle = self.inner.lifecycle.lock().await; + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + prune_finished_tasks(&mut state.pending); + if state + .latest_source_revisions + .get(source_id) + .is_some_and(|latest| *latest > source_revision) + { + return false; + } + state + .latest_source_revisions + .insert(source_id.to_owned(), source_revision); + if state + .active + .get(source_id) + .is_some_and(|watcher| watcher.source_revision > source_revision) + { + return false; + } + let watcher = state.active.remove(source_id); + let pending = take_pending_tasks(&mut state, source_id); + (watcher, pending) + }; + if let Some(watcher) = watcher { + stop_and_join_watcher(watcher).await; + } + join_tasks(pending).await; + true + } + + pub(crate) async fn retire_source(&self, source_id: &str) { + let _operation = self.inner.watcher_operations.lock().await; + let (watcher, pending) = { + let _lifecycle = self.inner.lifecycle.lock().await; + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + prune_finished_tasks(&mut state.pending); + state.retired_sources.insert(source_id.to_owned()); + let watcher = state.active.remove(source_id); + let pending = take_pending_tasks(&mut state, source_id); + (watcher, pending) + }; + if let Some(watcher) = watcher { + stop_and_join_watcher(watcher).await; + } + join_tasks(pending).await; + } + + pub(crate) async fn unretire_source(&self, source_id: &str) { + let _operation = self.inner.watcher_operations.lock().await; + let _lifecycle = self.inner.lifecycle.lock().await; + self.inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned") + .retired_sources + .remove(source_id); + } + + pub(crate) fn has_watcher(&self, source_id: &str) -> bool { + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + let finished = state + .active + .get(source_id) + .is_some_and(|watcher| watcher.task.is_finished()); + if finished { + let watcher = state.active.remove(source_id).expect("watcher exists"); + state.pending.push(PendingWatcherTask { + source_id: source_id.to_owned(), + task: watcher.task, + }); + false + } else { + state.active.contains_key(source_id) + } + } + + pub(crate) async fn shutdown(&self) { + self.begin_shutdown(); + let wait_for_idle = async { + loop { + let notified = self.inner.idle.notified(); + if self.inner.active_operations.load(Ordering::Acquire) == 0 { + break; + } + notified.await; + } + }; + if tokio::time::timeout(SHUTDOWN_GRACE, wait_for_idle) + .await + .is_err() + { + tracing::warn!("MCP connections did not drain before the shutdown deadline"); + } + + let _operation = self.inner.watcher_operations.lock().await; + let watchers = { + let _lifecycle = self.inner.lifecycle.lock().await; + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + for watcher in state.active.values_mut() { + cancel_watcher(watcher); + } + let mut watchers = state + .active + .drain() + .map(|(_, watcher)| watcher.task) + .collect::>(); + watchers.extend(state.pending.drain(..).map(|pending| pending.task)); + watchers + }; + drain_tasks(watchers).await; + } +} + +impl WatcherRevisionLease { + pub(crate) async fn lock_revision( + &self, + expected_revision: i64, + ) -> Option { + let lifecycle = self.inner.lifecycle.clone().lock_owned().await; + let state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + let is_current = state.active.get(&self.source_id).is_some_and(|watcher| { + watcher.generation == self.generation && watcher.source_revision == expected_revision + }); + if !is_current + || state.latest_source_revisions.get(&self.source_id) != Some(&expected_revision) + { + return None; + } + + drop(state); + Some(WatcherRevisionGuard { + inner: self.inner.clone(), + source_id: self.source_id.clone(), + generation: self.generation, + expected_revision, + _lifecycle: lifecycle, + }) + } +} + +impl WatcherRevisionGuard { + pub(crate) fn advance(self, new_revision: i64) -> bool { + if new_revision < self.expected_revision { + return false; + } + + let mut state = self + .inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + let is_current = state.active.get(&self.source_id).is_some_and(|watcher| { + watcher.generation == self.generation + && watcher.source_revision == self.expected_revision + }); + if !is_current + || state.latest_source_revisions.get(&self.source_id) != Some(&self.expected_revision) + { + return false; + } + state + .active + .get_mut(&self.source_id) + .expect("current watcher exists") + .source_revision = new_revision; + state + .latest_source_revisions + .insert(self.source_id.clone(), new_revision); + true + } +} + +struct WatcherCleanup { + inner: Arc, + source_id: String, + generation: u64, +} + +impl Drop for WatcherCleanup { + fn drop(&mut self) { + retire_watcher_if_current(&self.inner, &self.source_id, self.generation); + } +} + +fn cancel_watcher(watcher: &mut WatcherTask) { + if let Some(cancel) = watcher.cancel.take() { + let _ = cancel.send(()); + } +} + +async fn stop_and_join_watcher(mut watcher: WatcherTask) { + cancel_watcher(&mut watcher); + stop_and_join_task(watcher.task).await; +} + +async fn join_tasks(tasks: Vec>) { + for task in tasks { + stop_and_join_task(task).await; + } +} + +async fn stop_and_join_task(mut task: tokio::task::JoinHandle<()>) { + if tokio::time::timeout(SHUTDOWN_GRACE, &mut task) + .await + .is_err() + { + task.abort(); + let _ = task.await; + } +} + +async fn drain_tasks(tasks: Vec>) { + let deadline = tokio::time::Instant::now() + SHUTDOWN_GRACE; + let mut tasks = tasks.into_iter(); + while let Some(mut task) = tasks.next() { + if tokio::time::timeout_at(deadline, &mut task).await.is_err() { + tracing::warn!("MCP source watchers did not stop before the shutdown deadline"); + task.abort(); + let _ = task.await; + let remaining = tasks.collect::>(); + for task in &remaining { + task.abort(); + } + for task in remaining { + let _ = task.await; + } + return; + } + } +} + +fn prune_finished_tasks(tasks: &mut Vec) { + tasks.retain(|pending| !pending.task.is_finished()); +} + +fn take_pending_tasks( + state: &mut WatcherState, + source_id: &str, +) -> Vec> { + let mut matching = Vec::new(); + let mut remaining = Vec::with_capacity(state.pending.len()); + for pending in state.pending.drain(..) { + if pending.source_id == source_id { + matching.push(pending.task); + } else { + remaining.push(pending); + } + } + state.pending = remaining; + matching +} + +fn retire_watcher_if_current(inner: &ManagerInner, source_id: &str, generation: u64) { + let mut state = inner + .watcher_state + .lock() + .expect("MCP watcher mutex poisoned"); + if state + .active + .get(source_id) + .is_some_and(|watcher| watcher.generation == generation) + { + let watcher = state + .active + .remove(source_id) + .expect("current watcher exists"); + state.pending.push(PendingWatcherTask { + source_id: source_id.to_owned(), + task: watcher.task, + }); + } +} + +impl Drop for McpOperationGuard { + fn drop(&mut self) { + if self.inner.active_operations.fetch_sub(1, Ordering::AcqRel) == 1 { + self.inner.idle.notify_waiters(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicBool; + + use super::*; + + #[tokio::test] + async fn shutdown_rejects_new_work_and_waits_for_active_operations() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let operation = manager.begin_operation().expect("operation starts"); + manager.begin_shutdown(); + assert!(manager.begin_operation().is_err()); + + let waiting = tokio::spawn({ + let manager = manager.clone(); + async move { manager.shutdown().await } + }); + tokio::task::yield_now().await; + assert!(!waiting.is_finished()); + drop(operation); + waiting.await.expect("shutdown task completes"); + } + + #[tokio::test] + async fn watcher_replacement_waits_for_old_cleanup_and_natural_exit_is_removed() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let old_started = Arc::new(Notify::new()); + let release_old = Arc::new(Notify::new()); + manager + .replace_watcher("source".to_owned(), 1, { + let old_started = old_started.clone(); + let release_old = release_old.clone(); + move |mut canceled, _revision_lease| async move { + old_started.notify_one(); + let _ = (&mut canceled).await; + release_old.notified().await; + } + }) + .await + .expect("first watcher installs"); + old_started.notified().await; + + let replacement_started = Arc::new(AtomicBool::new(false)); + let replacing = tokio::spawn({ + let manager = manager.clone(); + let replacement_started = replacement_started.clone(); + async move { + manager + .replace_watcher( + "source".to_owned(), + 2, + move |_canceled, _revision_lease| async move { + replacement_started.store(true, Ordering::Release); + }, + ) + .await + } + }); + tokio::task::yield_now().await; + assert!(!replacement_started.load(Ordering::Acquire)); + assert!(!replacing.is_finished()); + release_old.notify_one(); + assert!(replacing.await.expect("replacement task completes").is_ok()); + tokio::time::timeout(Duration::from_secs(1), async { + while !replacement_started.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("replacement starts after old cleanup"); + tokio::time::timeout(Duration::from_secs(1), async { + while manager.has_watcher("source") { + tokio::task::yield_now().await; + } + }) + .await + .expect("naturally completed watcher removes its registry entry"); + } + + #[tokio::test] + async fn retired_source_atomically_rejects_install_until_unretired() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + manager.retire_source("source").await; + let started = Arc::new(AtomicBool::new(false)); + let installed = manager + .replace_watcher("source".to_owned(), 1, { + let started = started.clone(); + move |_canceled, _revision_lease| async move { + started.store(true, Ordering::Release); + } + }) + .await + .expect("manager remains available"); + assert!(!installed); + assert!(!started.load(Ordering::Acquire)); + assert!(!manager.has_watcher("source")); + + manager.unretire_source("source").await; + assert!( + manager + .replace_watcher( + "source".to_owned(), + 1, + move |_canceled, _revision_lease| async {}, + ) + .await + .expect("source installs after rollback") + ); + } + + #[tokio::test] + async fn stale_install_and_stop_cannot_replace_a_newer_watcher() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let canceled = Arc::new(Notify::new()); + manager + .replace_watcher("source".to_owned(), 2, { + let canceled = canceled.clone(); + move |mut stop, _revision_lease| async move { + let _ = (&mut stop).await; + canceled.notify_one(); + } + }) + .await + .expect("new watcher installs"); + + assert!( + !manager + .replace_watcher( + "source".to_owned(), + 1, + move |_stop, _revision_lease| async {}, + ) + .await + .expect("stale install is ignored") + ); + assert!(!manager.stop_watcher_at_revision("source", 1).await); + assert!(!manager.stop_watcher_and_wait_at_revision("source", 1).await); + assert!(manager.has_watcher("source")); + assert!( + tokio::time::timeout(Duration::from_millis(10), canceled.notified()) + .await + .is_err() + ); + assert!(manager.stop_watcher_and_wait_at_revision("source", 2).await); + } + + #[tokio::test] + async fn stale_watcher_generation_cannot_advance_a_replacement_revision() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let (lease_sender, lease_receiver) = tokio::sync::oneshot::channel(); + manager + .replace_watcher( + "source".to_owned(), + 1, + move |mut stop, revision_lease| async move { + assert!(lease_sender.send(revision_lease).is_ok()); + let _ = (&mut stop).await; + }, + ) + .await + .expect("first watcher installs"); + let stale_lease = lease_receiver.await.expect("first watcher provides lease"); + + manager + .replace_watcher( + "source".to_owned(), + 1, + move |mut stop, _revision_lease| async move { + let _ = (&mut stop).await; + }, + ) + .await + .expect("replacement watcher installs"); + + assert!(stale_lease.lock_revision(1).await.is_none()); + assert!(manager.has_watcher("source")); + assert!(manager.stop_watcher_and_wait_at_revision("source", 1).await); + } + + #[tokio::test] + async fn active_revision_advance_fences_stale_install_and_stop() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let (lease_sender, lease_receiver) = tokio::sync::oneshot::channel(); + manager + .replace_watcher( + "source".to_owned(), + 1, + move |mut stop, revision_lease| async move { + assert!(lease_sender.send(revision_lease).is_ok()); + let _ = (&mut stop).await; + }, + ) + .await + .expect("watcher installs"); + let revision_lease = lease_receiver.await.expect("watcher provides lease"); + + assert!( + revision_lease + .lock_revision(1) + .await + .expect("active revision locks") + .advance(2) + ); + assert!(revision_lease.lock_revision(1).await.is_none()); + assert!( + !revision_lease + .lock_revision(2) + .await + .expect("current revision locks") + .advance(1) + ); + assert!( + !manager + .replace_watcher( + "source".to_owned(), + 1, + move |_stop, _revision_lease| async {}, + ) + .await + .expect("stale install is ignored") + ); + assert!(!manager.stop_watcher_at_revision("source", 1).await); + assert!(!manager.stop_watcher_and_wait_at_revision("source", 1).await); + assert!(manager.has_watcher("source")); + assert!(manager.stop_watcher_and_wait_at_revision("source", 2).await); + } + + #[tokio::test] + async fn manual_refresh_replaces_the_old_watcher_revision_lease() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let (old_lease_sender, old_lease_receiver) = tokio::sync::oneshot::channel(); + let old_stopped = Arc::new(Notify::new()); + manager + .replace_watcher("source".to_owned(), 1, { + let old_stopped = old_stopped.clone(); + move |mut stop, revision_lease| async move { + assert!(old_lease_sender.send(revision_lease).is_ok()); + let _ = (&mut stop).await; + old_stopped.notify_one(); + } + }) + .await + .expect("initial watcher installs"); + let old_lease = old_lease_receiver + .await + .expect("initial watcher provides its revision lease"); + + let (refreshed_lease_sender, refreshed_lease_receiver) = tokio::sync::oneshot::channel(); + assert!( + manager + .replace_watcher( + "source".to_owned(), + 2, + move |mut stop, revision_lease| async move { + assert!(refreshed_lease_sender.send(revision_lease).is_ok()); + let _ = (&mut stop).await; + }, + ) + .await + .expect("manual refresh installs its committed revision") + ); + old_stopped.notified().await; + let refreshed_lease = refreshed_lease_receiver + .await + .expect("refreshed watcher provides its revision lease"); + + assert!(old_lease.lock_revision(1).await.is_none()); + assert!(refreshed_lease.lock_revision(1).await.is_none()); + assert!(refreshed_lease.lock_revision(2).await.is_some()); + assert!(manager.stop_watcher_and_wait_at_revision("source", 2).await); + } + + #[tokio::test] + async fn credential_rotation_guard_drains_before_shutdown_and_blocks_reinstall() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let rotation_started = Arc::new(Notify::new()); + let allow_commit = Arc::new(Notify::new()); + let committed = Arc::new(AtomicBool::new(false)); + let reinstall_started = Arc::new(AtomicBool::new(false)); + let rotating = tokio::spawn({ + let manager = manager.clone(); + let rotation_started = rotation_started.clone(); + let allow_commit = allow_commit.clone(); + let committed = committed.clone(); + let reinstall_started = reinstall_started.clone(); + async move { + let operation = manager + .begin_operation() + .expect("credential rotation begins before shutdown"); + rotation_started.notify_one(); + allow_commit.notified().await; + committed.store(true, Ordering::Release); + let reinstall = manager + .replace_watcher( + "source".to_owned(), + 2, + move |_stop, _revision_lease| async move { + reinstall_started.store(true, Ordering::Release); + }, + ) + .await; + drop(operation); + reinstall + } + }); + rotation_started.notified().await; + + manager.begin_shutdown(); + let shutdown = tokio::spawn({ + let manager = manager.clone(); + async move { manager.shutdown().await } + }); + tokio::task::yield_now().await; + assert!(!shutdown.is_finished()); + + allow_commit.notify_one(); + assert!( + rotating + .await + .expect("credential rotation task joins") + .is_err(), + "watcher reinstall is rejected once shutdown starts" + ); + shutdown.await.expect("shutdown drains credential rotation"); + assert!(committed.load(Ordering::Acquire)); + assert!(!reinstall_started.load(Ordering::Acquire)); + assert!(!manager.has_watcher("source")); + } + + #[tokio::test] + async fn revision_guard_blocks_replacement_and_stop_across_a_paused_commit() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let (old_lease_sender, old_lease_receiver) = tokio::sync::oneshot::channel(); + manager + .replace_watcher( + "source".to_owned(), + 1, + move |mut stop, revision_lease| async move { + assert!(old_lease_sender.send(revision_lease).is_ok()); + let _ = (&mut stop).await; + }, + ) + .await + .expect("first watcher installs"); + let old_lease = old_lease_receiver + .await + .expect("first watcher provides lease"); + let revision_guard = old_lease + .lock_revision(1) + .await + .expect("first watcher locks its revision"); + + let (new_lease_sender, new_lease_receiver) = tokio::sync::oneshot::channel(); + let replacement_attempted = Arc::new(Notify::new()); + let replacing = tokio::spawn({ + let manager = manager.clone(); + let replacement_attempted = replacement_attempted.clone(); + async move { + replacement_attempted.notify_one(); + manager + .replace_watcher( + "source".to_owned(), + 2, + move |mut stop, revision_lease| async move { + assert!(new_lease_sender.send(revision_lease).is_ok()); + let _ = (&mut stop).await; + }, + ) + .await + } + }); + replacement_attempted.notified().await; + assert!(!replacing.is_finished()); + + assert!(revision_guard.advance(2)); + assert!( + replacing + .await + .expect("replacement task completes") + .expect("manager remains available") + ); + let new_lease = new_lease_receiver + .await + .expect("replacement watcher provides lease"); + assert!(old_lease.lock_revision(2).await.is_none()); + + let revision_guard = new_lease + .lock_revision(2) + .await + .expect("replacement watcher locks its revision"); + let stop_attempted = Arc::new(Notify::new()); + let stopping = tokio::spawn({ + let manager = manager.clone(); + let stop_attempted = stop_attempted.clone(); + async move { + stop_attempted.notify_one(); + manager.stop_watcher_at_revision("source", 2).await + } + }); + stop_attempted.notified().await; + assert!(!stopping.is_finished()); + + assert!(revision_guard.advance(3)); + assert!(!stopping.await.expect("stop task completes")); + assert!(manager.has_watcher("source")); + assert!(manager.stop_watcher_and_wait_at_revision("source", 3).await); + } + + #[tokio::test] + async fn replacement_releases_lifecycle_before_joining_the_old_watcher() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let watcher_manager = manager.clone(); + manager + .replace_watcher( + "source".to_owned(), + 1, + move |mut stop, revision_lease| async move { + let _ = (&mut stop).await; + assert!(!watcher_manager.stop_watcher_at_revision("source", 1).await); + assert!(revision_lease.lock_revision(1).await.is_none()); + }, + ) + .await + .expect("first watcher installs"); + + let installed = tokio::time::timeout( + Duration::from_secs(1), + manager.replace_watcher( + "source".to_owned(), + 2, + move |_stop, _revision_lease| async {}, + ), + ) + .await + .expect("replacement does not deadlock on the revision lifecycle") + .expect("manager remains available"); + assert!(installed); + } + + #[tokio::test] + async fn panicked_watcher_is_removed_from_the_active_registry() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + manager + .replace_watcher( + "source".to_owned(), + 1, + move |_stop, _revision_lease| async move { + panic!("watcher fixture panic"); + }, + ) + .await + .expect("watcher installs"); + tokio::time::timeout(Duration::from_secs(1), async { + while manager.has_watcher("source") { + tokio::task::yield_now().await; + } + }) + .await + .expect("panicked watcher is removed"); + manager.shutdown().await; + } + + #[tokio::test] + async fn replacement_waits_for_a_nonblocking_self_stop_to_finish() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + manager + .replace_watcher("source".to_owned(), 1, { + let started = started.clone(); + let release = release.clone(); + move |mut canceled, _revision_lease| async move { + started.notify_one(); + let _ = (&mut canceled).await; + release.notified().await; + } + }) + .await + .expect("watcher installs"); + started.notified().await; + assert!(manager.stop_watcher_at_revision("source", 1).await); + + let replacement_started = Arc::new(AtomicBool::new(false)); + let replacing = tokio::spawn({ + let manager = manager.clone(); + let replacement_started = replacement_started.clone(); + async move { + manager + .replace_watcher( + "source".to_owned(), + 2, + move |_canceled, _revision_lease| async move { + replacement_started.store(true, Ordering::Release); + }, + ) + .await + } + }); + tokio::task::yield_now().await; + assert!(!replacing.is_finished()); + assert!(!replacement_started.load(Ordering::Acquire)); + release.notify_one(); + assert!(replacing.await.expect("replacement task completes").is_ok()); + } + + #[tokio::test] + async fn concurrent_replacement_and_retirement_leave_no_watcher() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let old_started = Arc::new(Notify::new()); + let release_old = Arc::new(Notify::new()); + manager + .replace_watcher("source".to_owned(), 1, { + let old_started = old_started.clone(); + let release_old = release_old.clone(); + move |mut canceled, _revision_lease| async move { + old_started.notify_one(); + let _ = (&mut canceled).await; + release_old.notified().await; + } + }) + .await + .expect("watcher installs"); + old_started.notified().await; + + let replacing = tokio::spawn({ + let manager = manager.clone(); + async move { + manager + .replace_watcher( + "source".to_owned(), + 2, + move |mut canceled, _revision_lease| async move { + let _ = (&mut canceled).await; + }, + ) + .await + } + }); + tokio::task::yield_now().await; + let retiring = tokio::spawn({ + let manager = manager.clone(); + async move { manager.retire_source("source").await } + }); + release_old.notify_one(); + + replacing + .await + .expect("replacement task completes") + .expect("manager remains available"); + retiring.await.expect("retirement task completes"); + assert!(!manager.has_watcher("source")); + assert!( + !manager + .replace_watcher( + "source".to_owned(), + 3, + move |_canceled, _revision_lease| async {}, + ) + .await + .expect("manager remains available") + ); + } + + #[tokio::test] + async fn shutdown_drains_a_nonblocking_self_stop() { + let manager = McpConnectionManager::new(StdioTemplateRegistry::default()); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + manager + .replace_watcher("source".to_owned(), 1, { + let started = started.clone(); + let release = release.clone(); + move |mut canceled, _revision_lease| async move { + started.notify_one(); + let _ = (&mut canceled).await; + release.notified().await; + } + }) + .await + .expect("watcher installs"); + started.notified().await; + assert!(manager.stop_watcher_at_revision("source", 1).await); + + let shutdown = tokio::spawn({ + let manager = manager.clone(); + async move { manager.shutdown().await } + }); + tokio::task::yield_now().await; + assert!(!shutdown.is_finished()); + release.notify_one(); + shutdown.await.expect("shutdown drains stopped watcher"); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 000000000..ca902a81c --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,4 @@ +pub mod discovery; +pub mod downstream; +pub(crate) mod manager; +pub mod upstream; diff --git a/src/mcp/upstream/http/mod.rs b/src/mcp/upstream/http/mod.rs new file mode 100644 index 000000000..d468b75ac --- /dev/null +++ b/src/mcp/upstream/http/mod.rs @@ -0,0 +1,1450 @@ +use std::{ + sync::{Arc, Mutex as StateMutex, MutexGuard}, + time::Duration, +}; + +use reqwest::{ + Method, StatusCode, + header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}, +}; +use rmcp::model::ProtocolVersion; +use serde_json::{Map, Value, json}; +use thiserror::Error; +use tokio::sync::{RwLock as AsyncRwLock, broadcast}; +use url::Url; + +#[cfg(test)] +use crate::outbound::OutboundResponse; +use crate::outbound::{ + HardenedHttpClient, OutboundError, OutboundPolicy, OutboundRequest, OutboundStreamResponse, + parse_url, +}; + +pub const DEFAULT_PROTOCOL_VERSION: &str = "2025-11-25"; + +const MCP_SESSION_ID: HeaderName = HeaderName::from_static("mcp-session-id"); +const MCP_PROTOCOL_VERSION: HeaderName = HeaderName::from_static("mcp-protocol-version"); +const LAST_EVENT_ID: HeaderName = HeaderName::from_static("last-event-id"); +const MAX_SESSION_ID_BYTES: usize = 1024; +const MAX_SSE_EVENTS: usize = 1024; +const MAX_SSE_RECONNECTS: usize = 3; +const MAX_LOGICAL_STREAM_BYTES: usize = 16 * 1024 * 1024; +const MCP_TOTAL_TIMEOUT: Duration = Duration::from_secs(30); +const NOTIFICATION_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +#[derive(Clone)] +pub struct StreamableHttpConfig { + pub endpoint: String, + pub headers: HeaderMap, + pub allow_private_networks: bool, + pub protocol_version: String, + pub client_name: String, + pub client_version: String, +} + +impl StreamableHttpConfig { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + headers: HeaderMap::new(), + allow_private_networks: false, + protocol_version: DEFAULT_PROTOCOL_VERSION.to_owned(), + client_name: "executor".to_owned(), + client_version: env!("CARGO_PKG_VERSION").to_owned(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ListToolsPage { + pub tools: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Error)] +pub enum StreamableHttpError { + #[error("the MCP HTTP configuration contains a reserved header")] + ReservedHeader, + #[error("the MCP protocol version is invalid")] + InvalidProtocolVersion, + #[error("the MCP client name or version is invalid")] + InvalidClientInfo, + #[error("the MCP request is not a valid JSON-RPC message")] + InvalidRequest, + #[error("the MCP response is not valid UTF-8")] + InvalidUtf8, + #[error("the MCP response has an unsupported content type")] + UnsupportedContentType, + #[error("the MCP response is malformed")] + InvalidResponse, + #[error("the MCP server returned HTTP {0}")] + HttpStatus(StatusCode), + #[error("the MCP session expired")] + SessionExpired, + #[error("the MCP session was invalidated while the request was in flight")] + SessionInvalidated, + #[error("the MCP server returned an invalid session identifier")] + InvalidSessionId, + #[error("the MCP server changed the active session identifier")] + SessionChanged, + #[error("the MCP server selected an unsupported protocol version")] + ProtocolVersionMismatch, + #[error("the MCP transport has not been initialized")] + NotInitialized, + #[error("the MCP transport is already initialized")] + AlreadyInitialized, + #[error("the MCP server returned a JSON-RPC error")] + JsonRpc { + code: i64, + message: String, + data: Option, + }, + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Outbound(#[from] OutboundError), +} + +#[derive(Default)] +struct TransportState { + session_id: Option, + negotiated_protocol_version: Option, + initialized: bool, + generation: u64, +} + +struct InitializationGuard { + state: Arc>, + generation: u64, + committed: bool, +} + +impl InitializationGuard { + fn new(state: Arc>, generation: u64) -> Self { + Self { + state, + generation, + committed: false, + } + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for InitializationGuard { + fn drop(&mut self) { + if self.committed { + return; + } + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.generation == self.generation { + state.generation = state.generation.wrapping_add(1); + state.session_id = None; + state.negotiated_protocol_version = None; + state.initialized = false; + } + } +} + +#[derive(Clone)] +/// Hardened request-response Streamable HTTP transport. +/// +/// SSE responses are processed incrementally with bounded events, bytes, and +/// deadlines. Interrupted streams resume through GET and `Last-Event-ID` when +/// the server supplied both a session and an event identifier. +pub struct StreamableHttpTransport { + endpoint: Url, + headers: HeaderMap, + client: HardenedHttpClient, + requested_protocol_version: ProtocolVersion, + client_name: Arc, + client_version: Arc, + state: Arc>, + lifecycle: Arc>, + tool_list_changed: broadcast::Sender<()>, +} + +impl StreamableHttpTransport { + pub fn new(config: StreamableHttpConfig) -> Result { + validate_custom_headers(&config.headers)?; + let requested_protocol_version = + serde_json::from_value::(Value::String(config.protocol_version)) + .map_err(|_| StreamableHttpError::InvalidProtocolVersion)?; + if requested_protocol_version != ProtocolVersion::V_2025_11_25 { + return Err(StreamableHttpError::InvalidProtocolVersion); + } + if config.client_name.trim().is_empty() + || config.client_version.trim().is_empty() + || config.client_name.len() > 256 + || config.client_version.len() > 256 + { + return Err(StreamableHttpError::InvalidClientInfo); + } + let policy = OutboundPolicy { + allow_private_networks: config.allow_private_networks, + max_redirects: 0, + ..OutboundPolicy::default() + }; + let endpoint = parse_url(&config.endpoint, &policy)?; + let (tool_list_changed, _) = broadcast::channel(16); + Ok(Self { + endpoint, + headers: config.headers, + client: HardenedHttpClient::new(policy), + requested_protocol_version, + client_name: Arc::from(config.client_name), + client_version: Arc::from(config.client_version), + state: Arc::new(StateMutex::new(TransportState::default())), + lifecycle: Arc::new(AsyncRwLock::new(())), + tool_list_changed, + }) + } + + pub async fn initialize(&self) -> Result { + let _lifecycle = self.lifecycle.write().await; + let generation = { + let state = self.lock_state(); + if state.initialized { + return Err(StreamableHttpError::AlreadyInitialized); + } + state.generation + }; + let mut initialization = InitializationGuard::new(self.state.clone(), generation); + + let id = json!(0); + let result = match self + .request_result( + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": self.requested_protocol_version, + "capabilities": {}, + "clientInfo": { + "name": self.client_name.as_ref(), + "version": self.client_version.as_ref() + } + } + }), + &id, + false, + true, + ) + .await + { + Ok(result) => result, + Err(error) => { + self.reset_state().await; + return Err(error); + } + }; + let Some(selected_version_value) = result + .as_object() + .and_then(|result| result.get("protocolVersion")) + else { + self.reset_state().await; + return Err(StreamableHttpError::InvalidResponse); + }; + let selected_version = + match serde_json::from_value::(selected_version_value.clone()) { + Ok(version) => version, + Err(_) => { + self.reset_state().await; + return Err(StreamableHttpError::InvalidResponse); + } + }; + if selected_version != self.requested_protocol_version + || selected_version != ProtocolVersion::V_2025_11_25 + { + self.reset_state().await; + return Err(StreamableHttpError::ProtocolVersionMismatch); + } + self.lock_state().negotiated_protocol_version = Some(selected_version); + + let notification = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + if let Err(error) = self.send_internal(notification, false, false).await { + self.reset_state().await; + return Err(error); + } + self.lock_state().initialized = true; + initialization.commit(); + Ok(result) + } + + pub async fn list_tools( + &self, + cursor: Option<&str>, + id: Value, + ) -> Result { + validate_id(&id)?; + let mut params = Map::new(); + if let Some(cursor) = cursor { + params.insert("cursor".to_owned(), Value::String(cursor.to_owned())); + } + let result = self + .request_result( + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/list", + "params": params + }), + &id, + true, + false, + ) + .await?; + let result = result + .as_object() + .ok_or(StreamableHttpError::InvalidResponse)?; + let tools = result + .get("tools") + .and_then(Value::as_array) + .cloned() + .ok_or(StreamableHttpError::InvalidResponse)?; + if tools.iter().any(|tool| !tool.is_object()) { + return Err(StreamableHttpError::InvalidResponse); + } + let next_cursor = match result.get("nextCursor") { + None | Some(Value::Null) => None, + Some(Value::String(cursor)) => Some(cursor.clone()), + Some(_) => return Err(StreamableHttpError::InvalidResponse), + }; + Ok(ListToolsPage { tools, next_cursor }) + } + + pub async fn call_tool( + &self, + name: &str, + arguments: Value, + id: Value, + ) -> Result { + validate_id(&id)?; + if name.is_empty() || name.len() > 1024 || !arguments.is_object() { + return Err(StreamableHttpError::InvalidRequest); + } + self.request_result( + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }), + &id, + true, + false, + ) + .await + } + + pub async fn terminate(&self) -> Result<(), StreamableHttpError> { + let _lifecycle = self.lifecycle.write().await; + let (session_id, protocol_version, generation) = { + let state = self.lock_state(); + ( + state.session_id.clone(), + state.negotiated_protocol_version.clone(), + state.generation, + ) + }; + let Some(session_id) = session_id else { + self.reset_state_if_generation(generation).await; + return Ok(()); + }; + let mut headers = self.headers.clone(); + headers.insert( + MCP_SESSION_ID, + HeaderValue::from_str(&session_id) + .map_err(|_| StreamableHttpError::InvalidSessionId)?, + ); + if let Some(protocol_version) = protocol_version { + headers.insert( + MCP_PROTOCOL_VERSION, + HeaderValue::from_str(protocol_version.as_str()) + .map_err(|_| StreamableHttpError::InvalidProtocolVersion)?, + ); + } + self.reset_state_if_generation(generation).await; + let response = self + .client + .execute(OutboundRequest { + method: Method::DELETE, + url: self.endpoint.clone(), + headers, + body: Vec::new(), + }) + .await; + let response = response?; + if response.status.is_success() || response.status == StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(StreamableHttpError::HttpStatus(response.status)) + } + } + + #[cfg(test)] + async fn session_id(&self) -> Option { + self.lock_state().session_id.clone() + } + + /// Signals are coalescible. A lagged receiver should perform one refresh + /// and then continue receiving rather than attempting to count events. + pub fn subscribe_tool_list_changed(&self) -> broadcast::Receiver<()> { + self.tool_list_changed.subscribe() + } + + /// Runs the optional standalone GET SSE stream until it closes or fails. + /// The caller owns cancellation and may restart this future after errors. + pub async fn listen_notifications(&self) -> Result<(), StreamableHttpError> { + let (session_id, protocol_version, generation) = { + let state = self.lock_state(); + if !state.initialized { + return Err(StreamableHttpError::NotInitialized); + } + ( + state.session_id.clone(), + state.negotiated_protocol_version.clone(), + state.generation, + ) + }; + let protocol_version = protocol_version.ok_or(StreamableHttpError::NotInitialized)?; + let mut response = self + .open_notification_stream(session_id.as_deref(), &protocol_version, None, true) + .await?; + let mut decoder = SseDecoder::default(); + loop { + if self.lock_state().generation != generation { + return Err(StreamableHttpError::SessionInvalidated); + } + if response.status == StatusCode::NOT_FOUND { + self.reset_state_if_generation(generation).await; + return Err(StreamableHttpError::SessionExpired); + } + if response.status != StatusCode::OK { + return Err(StreamableHttpError::HttpStatus(response.status)); + } + self.accept_response_session_or_reset(&response.headers, generation, false) + .await?; + if response.declared_response_too_large() { + return Err(OutboundError::ResponseBodyTooLarge.into()); + } + if response_content_type(&response.headers)? != ResponseContentType::Sse { + return Err(StreamableHttpError::UnsupportedContentType); + } + loop { + match response.next_chunk().await { + Ok(Some(chunk)) => { + for event in decoder.push(&chunk)? { + if let Some(message) = event.message { + self.handle_server_message(&message, generation).await?; + } + } + } + Ok(None) => break, + Err(error) if resumable_stream_error(&error) => { + decoder.reset_stream(); + break; + } + Err(error) => return Err(error.into()), + } + } + for event in decoder.finish()? { + if let Some(message) = event.message { + self.handle_server_message(&message, generation).await?; + } + } + let last_event_id = decoder.last_event_id().map(str::to_owned); + let retry_delay = decoder.retry_delay(); + if !retry_delay.is_zero() { + tokio::time::sleep(retry_delay).await; + } + self.ensure_generation(generation).await?; + decoder.reset_connection_limits(); + response = self + .open_notification_stream( + session_id.as_deref(), + &protocol_version, + last_event_id.as_deref(), + true, + ) + .await?; + } + } + + async fn request_result( + &self, + request: Value, + id: &Value, + require_initialized: bool, + accept_new_session: bool, + ) -> Result { + let messages = self + .send_internal(request, require_initialized, accept_new_session) + .await?; + response_result(messages, id) + } + + async fn send_internal( + &self, + message: Value, + require_initialized: bool, + accept_new_session: bool, + ) -> Result, StreamableHttpError> { + let _lifecycle = if require_initialized { + Some(self.lifecycle.read().await) + } else { + None + }; + tokio::time::timeout( + MCP_TOTAL_TIMEOUT, + self.send_internal_with_deadline(message, require_initialized, accept_new_session), + ) + .await + .map_err(|_| StreamableHttpError::Outbound(OutboundError::Timeout))? + } + + async fn send_internal_with_deadline( + &self, + message: Value, + require_initialized: bool, + accept_new_session: bool, + ) -> Result, StreamableHttpError> { + validate_outgoing_message(&message)?; + let (session_id, protocol_version, generation) = { + let state = self.lock_state(); + if require_initialized && !state.initialized { + return Err(StreamableHttpError::NotInitialized); + } + ( + state.session_id.clone(), + state.negotiated_protocol_version.clone(), + state.generation, + ) + }; + let mut headers = self.headers.clone(); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/json, text/event-stream"), + ); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + if let Some(session_id) = session_id.as_deref() { + headers.insert( + MCP_SESSION_ID, + HeaderValue::from_str(session_id) + .map_err(|_| StreamableHttpError::InvalidSessionId)?, + ); + } + if let Some(protocol_version) = protocol_version.as_ref() { + headers.insert( + MCP_PROTOCOL_VERSION, + HeaderValue::from_str(protocol_version.as_str()) + .map_err(|_| StreamableHttpError::InvalidProtocolVersion)?, + ); + } + let awaited_id = message + .as_object() + .and_then(|message| message.get("method")) + .and_then(|_| message.get("id")) + .cloned(); + let response = self + .client + .execute_streaming_headers_first(OutboundRequest { + method: Method::POST, + url: self.endpoint.clone(), + headers, + body: serde_json::to_vec(&message)?, + }) + .await?; + self.accept_streaming_response( + response, + generation, + accept_new_session, + session_id.as_deref(), + protocol_version.as_ref(), + awaited_id.as_ref(), + ) + .await + } + + async fn accept_streaming_response( + &self, + mut response: OutboundStreamResponse, + generation: u64, + accept_new_session: bool, + session_id: Option<&str>, + protocol_version: Option<&ProtocolVersion>, + awaited_id: Option<&Value>, + ) -> Result, StreamableHttpError> { + if self.lock_state().generation != generation { + return Err(StreamableHttpError::SessionInvalidated); + } + if response.status == StatusCode::NOT_FOUND && session_id.is_some() { + self.reset_state_if_generation(generation).await; + return Err(StreamableHttpError::SessionExpired); + } + if !response.status.is_success() { + return Err(StreamableHttpError::HttpStatus(response.status)); + } + if let Err(error) = self + .accept_response_session(&response.headers, generation, accept_new_session) + .await + { + if matches!( + error, + StreamableHttpError::InvalidSessionId | StreamableHttpError::SessionChanged + ) { + self.reset_state_if_generation(generation).await; + } + return Err(error); + } + if response.declared_response_too_large() { + return Err(OutboundError::ResponseBodyTooLarge.into()); + } + let active_session_id = { + let state = self.lock_state(); + if state.generation != generation { + return Err(StreamableHttpError::SessionInvalidated); + } + state.session_id.clone() + }; + if awaited_id.is_none() { + if response.status == StatusCode::ACCEPTED && response.next_chunk().await?.is_none() { + self.ensure_generation(generation).await?; + return Ok(Vec::new()); + } + return Err(StreamableHttpError::InvalidResponse); + } + if response.status == StatusCode::ACCEPTED || response.status == StatusCode::NO_CONTENT { + return Err(StreamableHttpError::InvalidResponse); + } + + match response_content_type(&response.headers)? { + ResponseContentType::Json => { + let body = collect_stream_body(&mut response).await?; + if body.is_empty() { + return Err(StreamableHttpError::InvalidResponse); + } + self.ensure_generation(generation).await?; + let messages = flatten_json_messages(serde_json::from_slice(&body)?)?; + self.handle_server_requests(&messages, generation).await?; + Ok(messages) + } + ResponseContentType::Sse => { + self.read_sse_with_resume( + response, + generation, + active_session_id.as_deref(), + protocol_version, + awaited_id, + ) + .await + } + } + } + + async fn read_sse_with_resume( + &self, + mut response: OutboundStreamResponse, + generation: u64, + session_id: Option<&str>, + protocol_version: Option<&ProtocolVersion>, + awaited_id: Option<&Value>, + ) -> Result, StreamableHttpError> { + let mut decoder = SseDecoder::default(); + let mut messages = Vec::new(); + let mut reconnects = 0_usize; + let mut total_bytes = 0_usize; + loop { + self.ensure_generation(generation).await?; + loop { + match response.next_chunk().await { + Ok(Some(chunk)) => { + total_bytes = total_bytes.checked_add(chunk.len()).ok_or( + StreamableHttpError::Outbound(OutboundError::ResponseBodyTooLarge), + )?; + if total_bytes > MAX_LOGICAL_STREAM_BYTES { + return Err(OutboundError::ResponseBodyTooLarge.into()); + } + for event in decoder.push(&chunk)? { + if let Some(message) = event.message { + let is_response = + self.handle_server_message(&message, generation).await?; + let complete = is_response && message.get("id") == awaited_id; + if is_response { + messages.push(message); + } + if complete { + self.ensure_generation(generation).await?; + return Ok(messages); + } + } + } + } + Ok(None) => break, + Err(error) + if resumable_stream_error(&error) && decoder.last_event_id().is_some() => + { + decoder.reset_stream(); + break; + } + Err(error) => return Err(error.into()), + } + } + for event in decoder.finish()? { + if let Some(message) = event.message { + let is_response = self.handle_server_message(&message, generation).await?; + let complete = is_response && message.get("id") == awaited_id; + if is_response { + messages.push(message); + } + if complete { + self.ensure_generation(generation).await?; + return Ok(messages); + } + } + } + if awaited_id.is_none() && !messages.is_empty() { + return Ok(messages); + } + let last_event_id = decoder + .last_event_id() + .ok_or(StreamableHttpError::InvalidResponse)? + .to_owned(); + let retry_delay = decoder.retry_delay(); + response = loop { + if reconnects >= MAX_SSE_RECONNECTS { + return Err(StreamableHttpError::InvalidResponse); + } + reconnects += 1; + if !retry_delay.is_zero() { + tokio::time::sleep(retry_delay).await; + } + self.ensure_generation(generation).await?; + match self + .resume_stream(session_id, protocol_version, &last_event_id) + .await + { + Ok(response) if response.status == StatusCode::NOT_FOUND => { + self.reset_state_if_generation(generation).await; + return Err(StreamableHttpError::SessionExpired); + } + Ok(response) if response.status.is_server_error() => continue, + Ok(response) if response.status != StatusCode::OK => { + return Err(StreamableHttpError::HttpStatus(response.status)); + } + Ok(response) => break response, + Err(StreamableHttpError::Outbound(error)) if resumable_stream_error(&error) => { + continue; + } + Err(error) => return Err(error), + } + }; + self.ensure_generation(generation).await?; + self.accept_response_session_or_reset(&response.headers, generation, false) + .await?; + if response.declared_response_too_large() { + return Err(OutboundError::ResponseBodyTooLarge.into()); + } + if response_content_type(&response.headers)? != ResponseContentType::Sse { + return Err(StreamableHttpError::UnsupportedContentType); + } + decoder.reset_stream(); + } + } + + async fn resume_stream( + &self, + session_id: Option<&str>, + protocol_version: Option<&ProtocolVersion>, + last_event_id: &str, + ) -> Result { + self.open_notification_stream( + session_id, + protocol_version.unwrap_or(&self.requested_protocol_version), + Some(last_event_id), + false, + ) + .await + } + + async fn open_notification_stream( + &self, + session_id: Option<&str>, + protocol_version: &ProtocolVersion, + last_event_id: Option<&str>, + long_lived: bool, + ) -> Result { + let mut headers = self.headers.clone(); + headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); + if let Some(session_id) = session_id { + headers.insert( + MCP_SESSION_ID, + HeaderValue::from_str(session_id) + .map_err(|_| StreamableHttpError::InvalidSessionId)?, + ); + } + if let Some(last_event_id) = last_event_id { + headers.insert( + LAST_EVENT_ID, + HeaderValue::from_str(last_event_id) + .map_err(|_| StreamableHttpError::InvalidResponse)?, + ); + } + headers.insert( + MCP_PROTOCOL_VERSION, + HeaderValue::from_str(protocol_version.as_str()) + .map_err(|_| StreamableHttpError::InvalidProtocolVersion)?, + ); + let request = OutboundRequest { + method: Method::GET, + url: self.endpoint.clone(), + headers, + body: Vec::new(), + }; + if long_lived { + self.client + .execute_long_lived_streaming(request, NOTIFICATION_IDLE_TIMEOUT) + .await + .map_err(Into::into) + } else { + self.client + .execute_streaming_headers_first(request) + .await + .map_err(Into::into) + } + } + + async fn handle_server_requests( + &self, + messages: &[Value], + generation: u64, + ) -> Result<(), StreamableHttpError> { + for message in messages { + self.handle_server_message(message, generation).await?; + } + Ok(()) + } + + async fn handle_server_message( + &self, + message: &Value, + generation: u64, + ) -> Result { + let request = match classify_incoming_message(message)? { + IncomingMessage::Response => return Ok(true), + IncomingMessage::Notification { method } => { + if method == "notifications/tools/list_changed" { + let _ = self.tool_list_changed.send(()); + } + return Ok(false); + } + IncomingMessage::Request(request) => request, + }; + let response = if request.method == "ping" { + json!({ "jsonrpc": "2.0", "id": request.id, "result": {} }) + } else { + json!({ + "jsonrpc": "2.0", + "id": request.id, + "error": { "code": -32601, "message": "Method not found" } + }) + }; + self.post_auxiliary_response(response, generation).await?; + Ok(false) + } + + async fn post_auxiliary_response( + &self, + response: Value, + generation: u64, + ) -> Result<(), StreamableHttpError> { + validate_outgoing_message(&response)?; + let (session_id, protocol_version) = { + let state = self.lock_state(); + if state.generation != generation { + return Err(StreamableHttpError::SessionInvalidated); + } + ( + state.session_id.clone(), + state + .negotiated_protocol_version + .clone() + .unwrap_or_else(|| self.requested_protocol_version.clone()), + ) + }; + let mut headers = self.headers.clone(); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/json, text/event-stream"), + ); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + if let Some(session_id) = session_id.as_deref() { + headers.insert( + MCP_SESSION_ID, + HeaderValue::from_str(session_id) + .map_err(|_| StreamableHttpError::InvalidSessionId)?, + ); + } + headers.insert( + MCP_PROTOCOL_VERSION, + HeaderValue::from_str(protocol_version.as_str()) + .map_err(|_| StreamableHttpError::InvalidProtocolVersion)?, + ); + let auxiliary = self + .client + .execute_streaming_headers_first(OutboundRequest { + method: Method::POST, + url: self.endpoint.clone(), + headers, + body: serde_json::to_vec(&response)?, + }) + .await?; + self.ensure_generation(generation).await?; + if auxiliary.status == StatusCode::NOT_FOUND && session_id.is_some() { + self.reset_state_if_generation(generation).await; + return Err(StreamableHttpError::SessionExpired); + } + self.accept_response_session_or_reset(&auxiliary.headers, generation, false) + .await?; + if auxiliary.declared_response_too_large() { + return Err(OutboundError::ResponseBodyTooLarge.into()); + } + if auxiliary.status != StatusCode::ACCEPTED { + return Err(StreamableHttpError::InvalidResponse); + } + let mut auxiliary = auxiliary; + if auxiliary.next_chunk().await?.is_some() { + return Err(StreamableHttpError::InvalidResponse); + } + Ok(()) + } + + async fn accept_response_session( + &self, + headers: &HeaderMap, + generation: u64, + accept_new_session: bool, + ) -> Result<(), StreamableHttpError> { + let mut session_values = headers.get_all(&MCP_SESSION_ID).iter(); + let Some(session_id) = session_values.next() else { + return Ok(()); + }; + if session_values.next().is_some() { + return Err(StreamableHttpError::InvalidSessionId); + } + let session_id = session_id + .to_str() + .map_err(|_| StreamableHttpError::InvalidSessionId)?; + validate_session_id(session_id)?; + let mut state = self.lock_state(); + if state.generation != generation { + return Err(StreamableHttpError::SessionInvalidated); + } + match state.session_id.as_deref() { + Some(current) if current != session_id => Err(StreamableHttpError::SessionChanged), + Some(_) => Ok(()), + None if accept_new_session => { + state.session_id = Some(session_id.to_owned()); + Ok(()) + } + None => Err(StreamableHttpError::InvalidSessionId), + } + } + + async fn accept_response_session_or_reset( + &self, + headers: &HeaderMap, + generation: u64, + accept_new_session: bool, + ) -> Result<(), StreamableHttpError> { + match self + .accept_response_session(headers, generation, accept_new_session) + .await + { + Ok(()) => Ok(()), + Err(error) + if matches!( + error, + StreamableHttpError::InvalidSessionId | StreamableHttpError::SessionChanged + ) => + { + self.reset_state_if_generation(generation).await; + Err(error) + } + Err(error) => Err(error), + } + } + + fn lock_state(&self) -> MutexGuard<'_, TransportState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + async fn ensure_generation(&self, generation: u64) -> Result<(), StreamableHttpError> { + if self.lock_state().generation == generation { + Ok(()) + } else { + Err(StreamableHttpError::SessionInvalidated) + } + } + + async fn reset_state(&self) { + let mut state = self.lock_state(); + state.generation = state.generation.wrapping_add(1); + state.session_id = None; + state.negotiated_protocol_version = None; + state.initialized = false; + } + + async fn reset_state_if_generation(&self, generation: u64) { + let mut state = self.lock_state(); + if state.generation == generation { + state.generation = state.generation.wrapping_add(1); + state.session_id = None; + state.negotiated_protocol_version = None; + state.initialized = false; + } + } +} + +fn validate_custom_headers(headers: &HeaderMap) -> Result<(), StreamableHttpError> { + if headers.contains_key(ACCEPT) + || headers.contains_key(CONTENT_TYPE) + || headers.contains_key(&MCP_SESSION_ID) + || headers.contains_key(&MCP_PROTOCOL_VERSION) + || headers.contains_key(&LAST_EVENT_ID) + { + Err(StreamableHttpError::ReservedHeader) + } else { + Ok(()) + } +} + +fn validate_session_id(value: &str) -> Result<(), StreamableHttpError> { + if value.is_empty() + || value.len() > MAX_SESSION_ID_BYTES + || !value.bytes().all(|byte| byte.is_ascii_graphic()) + { + Err(StreamableHttpError::InvalidSessionId) + } else { + Ok(()) + } +} + +fn resumable_stream_error(error: &OutboundError) -> bool { + matches!( + error, + OutboundError::Timeout | OutboundError::Connection | OutboundError::Request + ) +} + +fn validate_id(id: &Value) -> Result<(), StreamableHttpError> { + if id.is_string() + || id + .as_number() + .is_some_and(|number| number.as_i64().is_some() || number.as_u64().is_some()) + { + Ok(()) + } else { + Err(StreamableHttpError::InvalidRequest) + } +} + +fn validate_outgoing_message(message: &Value) -> Result<(), StreamableHttpError> { + let messages = match message { + Value::Object(_) => std::slice::from_ref(message), + _ => return Err(StreamableHttpError::InvalidRequest), + }; + for message in messages { + let message = message + .as_object() + .ok_or(StreamableHttpError::InvalidRequest)?; + if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(StreamableHttpError::InvalidRequest); + } + let has_method = message.get("method").and_then(Value::as_str).is_some(); + let has_result = message.contains_key("result"); + let has_error = message.contains_key("error"); + if has_method && (has_result || has_error) { + return Err(StreamableHttpError::InvalidRequest); + } + if has_method { + if let Some(id) = message.get("id") { + validate_id(id)?; + } + } else { + let id = message + .get("id") + .ok_or(StreamableHttpError::InvalidRequest)?; + validate_id(id)?; + if has_result == has_error { + return Err(StreamableHttpError::InvalidRequest); + } + } + } + Ok(()) +} + +#[cfg(test)] +fn parse_response_messages(response: &OutboundResponse) -> Result, StreamableHttpError> { + match response_content_type(&response.headers)? { + ResponseContentType::Json => flatten_json_messages(serde_json::from_slice(&response.body)?), + ResponseContentType::Sse => parse_sse(&response.body), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResponseContentType { + Json, + Sse, +} + +fn response_content_type(headers: &HeaderMap) -> Result { + let mut content_types = headers.get_all(CONTENT_TYPE).iter(); + let content_type = content_types + .next() + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim) + .ok_or(StreamableHttpError::UnsupportedContentType)?; + if content_types.next().is_some() { + return Err(StreamableHttpError::UnsupportedContentType); + } + if content_type.eq_ignore_ascii_case("application/json") { + Ok(ResponseContentType::Json) + } else if content_type.eq_ignore_ascii_case("text/event-stream") { + Ok(ResponseContentType::Sse) + } else { + Err(StreamableHttpError::UnsupportedContentType) + } +} + +async fn collect_stream_body( + response: &mut OutboundStreamResponse, +) -> Result, StreamableHttpError> { + let mut body = Vec::new(); + while let Some(chunk) = response.next_chunk().await? { + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn flatten_json_messages(value: Value) -> Result, StreamableHttpError> { + match value { + Value::Object(_) => Ok(vec![value]), + _ => Err(StreamableHttpError::InvalidResponse), + } +} + +#[cfg(test)] +fn parse_sse(body: &[u8]) -> Result, StreamableHttpError> { + let mut decoder = SseDecoder::default(); + let mut messages = Vec::new(); + for event in decoder.push(body)?.into_iter().chain(decoder.finish()?) { + if let Some(message) = event.message { + messages.push(message); + } + } + if messages.is_empty() { + Err(StreamableHttpError::InvalidResponse) + } else { + Ok(messages) + } +} + +struct SseEvent { + message: Option, +} + +struct SseDecoder { + pending: Vec, + scan_offset: usize, + data: String, + has_data: bool, + event_id: Option, + last_event_id: Option, + event_count: usize, + retry_delay: Duration, + event_bytes: usize, + stream_start: bool, +} + +impl Default for SseDecoder { + fn default() -> Self { + Self { + pending: Vec::new(), + scan_offset: 0, + data: String::new(), + has_data: false, + event_id: None, + last_event_id: None, + event_count: 0, + retry_delay: Duration::from_millis(250), + event_bytes: 0, + stream_start: true, + } + } +} + +impl SseDecoder { + fn push(&mut self, chunk: &[u8]) -> Result, StreamableHttpError> { + self.pending.extend_from_slice(chunk); + let mut consumed = 0_usize; + let mut events = Vec::new(); + let mut cursor = self.scan_offset.min(self.pending.len()); + while cursor < self.pending.len() { + let delimiter = match self.pending[cursor] { + b'\n' => Some((cursor, 1)), + b'\r' if cursor + 1 < self.pending.len() => Some(( + cursor, + if self.pending[cursor + 1] == b'\n' { + 2 + } else { + 1 + }, + )), + b'\r' => None, + _ => { + cursor += 1; + continue; + } + }; + let Some((end, delimiter_bytes)) = delimiter else { + break; + }; + let line = self.pending[consumed..end].to_vec(); + consumed = end + delimiter_bytes; + cursor = consumed; + if let Some(event) = self.line(&line)? { + events.push(event); + } + } + self.pending.drain(..consumed); + self.scan_offset = self.pending.len(); + if self.pending.last() == Some(&b'\r') { + self.scan_offset = self.scan_offset.saturating_sub(1); + } + if self.pending.len() > MAX_LOGICAL_STREAM_BYTES { + return Err(StreamableHttpError::InvalidResponse); + } + Ok(events) + } + + fn finish(&mut self) -> Result, StreamableHttpError> { + let mut events = Vec::new(); + if self.pending.last() == Some(&b'\r') { + let mut line = std::mem::take(&mut self.pending); + line.pop(); + if let Some(event) = self.line(&line)? { + events.push(event); + } + } + self.pending.clear(); + self.scan_offset = 0; + self.data.clear(); + self.has_data = false; + self.event_id = None; + self.event_bytes = 0; + Ok(events) + } + + fn line(&mut self, line: &[u8]) -> Result, StreamableHttpError> { + let line = if self.stream_start { + self.stream_start = false; + line.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(line) + } else { + line + }; + self.event_bytes = self + .event_bytes + .checked_add(line.len()) + .ok_or(StreamableHttpError::InvalidResponse)?; + if self.event_bytes > MAX_LOGICAL_STREAM_BYTES { + return Err(StreamableHttpError::InvalidResponse); + } + let line = std::str::from_utf8(line).map_err(|_| StreamableHttpError::InvalidUtf8)?; + if line.is_empty() { + return self.dispatch(); + } + if line.starts_with(':') { + return Ok(None); + } + let (field, value) = line.split_once(':').unwrap_or((line, "")); + let value = value.strip_prefix(' ').unwrap_or(value); + match field { + "data" => { + if self.has_data { + self.data.push('\n'); + } + self.data.push_str(value); + self.has_data = true; + if self.data.len() > MAX_LOGICAL_STREAM_BYTES { + return Err(StreamableHttpError::InvalidResponse); + } + } + "id" if !value.contains('\0') => { + if value.len() > MAX_SESSION_ID_BYTES { + return Err(StreamableHttpError::InvalidResponse); + } + self.event_id = Some(value.to_owned()); + } + "retry" => { + if let Ok(milliseconds) = value.parse::() { + self.retry_delay = Duration::from_millis(milliseconds.clamp(50, 10_000)); + } + } + _ => {} + } + Ok(None) + } + + fn dispatch(&mut self) -> Result, StreamableHttpError> { + self.event_bytes = 0; + if !self.has_data && self.event_id.is_none() { + return Ok(None); + } + self.event_count = self + .event_count + .checked_add(1) + .ok_or(StreamableHttpError::InvalidResponse)?; + if self.event_count > MAX_SSE_EVENTS { + return Err(StreamableHttpError::InvalidResponse); + } + if let Some(event_id) = self.event_id.take() { + if event_id.is_empty() { + self.last_event_id = None; + } else { + self.last_event_id = Some(event_id); + } + } + let data = std::mem::take(&mut self.data); + self.has_data = false; + let message: Option = if data.is_empty() { + None + } else { + Some(serde_json::from_str(&data)?) + }; + if message.as_ref().is_some_and(|message| !message.is_object()) { + return Err(StreamableHttpError::InvalidResponse); + } + Ok(Some(SseEvent { message })) + } + + fn last_event_id(&self) -> Option<&str> { + self.last_event_id.as_deref() + } + + fn reset_stream(&mut self) { + self.pending.clear(); + self.scan_offset = 0; + self.data.clear(); + self.has_data = false; + self.event_id = None; + self.event_bytes = 0; + self.stream_start = true; + } + + fn retry_delay(&self) -> Duration { + self.retry_delay + } + + fn reset_connection_limits(&mut self) { + self.reset_stream(); + self.event_count = 0; + } +} + +struct ServerRequest { + id: Value, + method: String, +} + +enum IncomingMessage { + Response, + Request(ServerRequest), + Notification { method: String }, +} + +fn classify_incoming_message(message: &Value) -> Result { + let message = message + .as_object() + .ok_or(StreamableHttpError::InvalidResponse)?; + if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(StreamableHttpError::InvalidResponse); + } + let method = message.get("method").and_then(Value::as_str); + let has_result = message.contains_key("result"); + let has_error = message.contains_key("error"); + if let Some(method) = method { + if has_result || has_error { + return Err(StreamableHttpError::InvalidResponse); + } + return match message.get("id") { + Some(id) => { + validate_id(id).map_err(|_| StreamableHttpError::InvalidResponse)?; + Ok(IncomingMessage::Request(ServerRequest { + id: id.clone(), + method: method.to_owned(), + })) + } + None => Ok(IncomingMessage::Notification { + method: method.to_owned(), + }), + }; + } + let id = message + .get("id") + .ok_or(StreamableHttpError::InvalidResponse)?; + validate_id(id).map_err(|_| StreamableHttpError::InvalidResponse)?; + if has_result == has_error { + return Err(StreamableHttpError::InvalidResponse); + } + Ok(IncomingMessage::Response) +} + +fn response_result(messages: Vec, id: &Value) -> Result { + let response = messages + .into_iter() + .find(|message| message.get("id") == Some(id)) + .ok_or(StreamableHttpError::InvalidResponse)?; + let response = response + .as_object() + .ok_or(StreamableHttpError::InvalidResponse)?; + if response.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(StreamableHttpError::InvalidResponse); + } + if let Some(error) = response.get("error") { + let error = error + .as_object() + .ok_or(StreamableHttpError::InvalidResponse)?; + return Err(StreamableHttpError::JsonRpc { + code: error + .get("code") + .and_then(Value::as_i64) + .ok_or(StreamableHttpError::InvalidResponse)?, + message: error + .get("message") + .and_then(Value::as_str) + .ok_or(StreamableHttpError::InvalidResponse)? + .to_owned(), + data: error.get("data").cloned(), + }); + } + response + .get("result") + .cloned() + .ok_or(StreamableHttpError::InvalidResponse) +} + +#[cfg(test)] +mod tests; diff --git a/src/mcp/upstream/http/tests.rs b/src/mcp/upstream/http/tests.rs new file mode 100644 index 000000000..cf9fc2c2d --- /dev/null +++ b/src/mcp/upstream/http/tests.rs @@ -0,0 +1,1163 @@ +use std::{collections::VecDeque, sync::Arc}; + +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Mutex, +}; + +use super::{ + StreamableHttpConfig, StreamableHttpError, StreamableHttpTransport, parse_response_messages, +}; +use crate::outbound::OutboundResponse; + +struct TestServer { + endpoint: String, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl TestServer { + async fn start(responses: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let endpoint = format!( + "http://{}/mcp", + listener.local_addr().expect("listener has an address") + ); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = requests.clone(); + let responses = Arc::new(Mutex::new(VecDeque::from(responses))); + let task = tokio::spawn(async move { + loop { + let Some(response) = responses.lock().await.pop_front() else { + break; + }; + let (mut stream, _) = listener.accept().await.expect("request is accepted"); + let mut bytes = vec![0_u8; 64 * 1024]; + let read = stream.read(&mut bytes).await.expect("request is read"); + captured + .lock() + .await + .push(String::from_utf8_lossy(&bytes[..read]).into_owned()); + stream + .write_all(response.as_bytes()) + .await + .expect("response is written"); + } + }); + Self { + endpoint, + requests, + task, + } + } + + async fn finish(self) -> Vec { + self.task.await.expect("test server completes"); + Arc::try_unwrap(self.requests) + .expect("request capture has one owner") + .into_inner() + } +} + +fn response(status: &str, headers: &[(&str, &str)], body: &str) -> String { + let mut response = format!("HTTP/1.1 {status}\r\n"); + for (name, value) in headers { + response.push_str(name); + response.push_str(": "); + response.push_str(value); + response.push_str("\r\n"); + } + response.push_str(&format!( + "Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )); + response +} + +fn make_transport(endpoint: String) -> StreamableHttpTransport { + let mut config = StreamableHttpConfig::new(endpoint); + config.allow_private_networks = true; + StreamableHttpTransport::new(config).expect("test transport is valid") +} + +#[tokio::test] +async fn initialization_pins_session_and_protocol_for_followup_requests() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "test", "version": "1" } + } + }) + .to_string(); + let tools = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { "tools": [{ "name": "ping", "inputSchema": { "type": "object" } }] } + }) + .to_string(); + let server = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "session-1"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + response("200 OK", &[("Content-Type", "application/json")], &tools), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + + transport + .initialize() + .await + .expect("initialization succeeds"); + let page = transport + .list_tools(None, json!(1)) + .await + .expect("tools list succeeds"); + assert_eq!(page.tools[0]["name"], "ping"); + assert_eq!(transport.session_id().await.as_deref(), Some("session-1")); + + let requests = server.finish().await; + assert!( + !requests[0] + .to_ascii_lowercase() + .contains("mcp-protocol-version:") + ); + for request in &requests[1..] { + let request = request.to_ascii_lowercase(); + assert!(request.contains("mcp-session-id: session-1")); + assert!(request.contains("mcp-protocol-version: 2025-11-25")); + assert!(request.contains("accept: application/json, text/event-stream")); + assert!(!request.contains("last-event-id:")); + } +} + +#[tokio::test] +async fn call_tool_accepts_sse_and_rejects_server_requests() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let call_sse = concat!( + "event: message\n", + "data: {\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}\n\n", + "event: message\n", + "data: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"content\":[]}}\n\n" + ); + let server_request = "data: {\"jsonrpc\":\"2.0\",\"id\":\"server-1\",\"method\":\"elicitation/create\",\"params\":{}}\n\n"; + let server = TestServer::start(vec![ + response( + "200 OK", + &[("Content-Type", "application/json")], + &initialize, + ), + response("202 Accepted", &[], ""), + response("200 OK", &[("Content-Type", "text/event-stream")], call_sse), + response("202 Accepted", &[], ""), + response( + "200 OK", + &[("Content-Type", "text/event-stream")], + server_request, + ), + response("202 Accepted", &[], ""), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + + let result = transport + .call_tool("ping", json!({}), json!(7)) + .await + .expect("SSE tool response succeeds"); + assert_eq!(result, json!({ "content": [] })); + let error = transport + .send_internal( + json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/list", + "params": {} + }), + true, + false, + ) + .await + .expect_err("server request is rejected"); + assert!(matches!(error, StreamableHttpError::InvalidResponse)); + let requests = server.finish().await; + assert!(requests[3].contains("\"id\":7")); + assert!(requests[3].contains("\"result\":{}")); + assert!(requests[5].contains("\"code\":-32601")); +} + +#[tokio::test] +async fn changed_and_expired_sessions_fail_closed() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let tools = json!({ "jsonrpc": "2.0", "id": 1, "result": { "tools": [] } }).to_string(); + let changed = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "one"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "two"), + ], + &tools, + ), + ]) + .await; + let transport = make_transport(changed.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + assert!(matches!( + transport.list_tools(None, json!(1)).await, + Err(StreamableHttpError::SessionChanged) + )); + changed.finish().await; + + let expired = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "one"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + response("404 Not Found", &[], ""), + ]) + .await; + let transport = make_transport(expired.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + assert!(matches!( + transport.list_tools(None, json!(1)).await, + Err(StreamableHttpError::SessionExpired) + )); + assert_eq!(transport.session_id().await, None); + expired.finish().await; +} + +#[tokio::test] +async fn termination_uses_delete_and_clears_memory() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let server = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "one"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + response("204 No Content", &[], ""), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + transport.terminate().await.expect("termination succeeds"); + assert_eq!(transport.session_id().await, None); + + let requests = server.finish().await; + assert!(requests[2].starts_with("DELETE /mcp HTTP/1.1")); + assert!( + requests[2] + .to_ascii_lowercase() + .contains("mcp-session-id: one") + ); +} + +#[tokio::test] +async fn termination_invalidates_before_waiting_for_delete_response() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let endpoint = format!( + "http://{}/mcp", + listener.local_addr().expect("listener has an address") + ); + let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("DELETE is accepted"); + let mut request = vec![0_u8; 8192]; + let read = stream.read(&mut request).await.expect("DELETE is read"); + assert!(read > 0, "DELETE request is not empty"); + accepted_tx.send(()).expect("test signal sends"); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + stream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await + .expect("DELETE response writes"); + }); + let transport = make_transport(endpoint); + { + let mut state = transport.lock_state(); + state.initialized = true; + state.session_id = Some("terminating".to_owned()); + state.negotiated_protocol_version = Some(rmcp::model::ProtocolVersion::V_2025_11_25); + } + let terminating = { + let transport = transport.clone(); + tokio::spawn(async move { transport.terminate().await }) + }; + accepted_rx.await.expect("DELETE reaches the server"); + + assert!(matches!( + transport.list_tools(None, json!(1)).await, + Err(StreamableHttpError::NotInitialized) + )); + terminating + .await + .expect("termination task completes") + .expect("termination succeeds"); + server.await.expect("server task completes"); +} + +#[tokio::test] +async fn protocol_mismatch_discards_the_server_session() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-03-26", "capabilities": {} } + }) + .to_string(); + let server = TestServer::start(vec![response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "unusable"), + ], + &initialize, + )]) + .await; + let transport = make_transport(server.endpoint.clone()); + + assert!(matches!( + transport.initialize().await, + Err(StreamableHttpError::ProtocolVersionMismatch) + )); + assert_eq!(transport.session_id().await, None); + assert!(matches!( + transport.list_tools(None, json!(1)).await, + Err(StreamableHttpError::NotInitialized) + )); + server.finish().await; +} + +#[tokio::test] +async fn aborted_initialize_discards_provisional_session_state() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let endpoint = format!( + "http://{}/mcp", + listener.local_addr().expect("listener has an address") + ); + let (headers_tx, headers_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("initialize is accepted"); + let mut request = vec![0_u8; 8192]; + let read = stream.read(&mut request).await.expect("initialize is read"); + assert!(read > 0, "initialize request is not empty"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nMcp-Session-Id: provisional\r\n\r\n", + ) + .await + .expect("initialize headers write"); + stream.flush().await.expect("initialize headers flush"); + headers_tx.send(()).expect("header signal sends"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + let transport = make_transport(endpoint); + let initializing = { + let transport = transport.clone(); + tokio::spawn(async move { transport.initialize().await }) + }; + headers_rx.await.expect("initialize headers arrive"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + initializing.abort(); + let _ = initializing.await; + + let state = transport.lock_state(); + assert!(!state.initialized); + assert_eq!(state.session_id, None); + assert_eq!(state.negotiated_protocol_version, None); + drop(state); + server.abort(); +} + +#[tokio::test] +async fn initialized_notification_requires_empty_202() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let server = TestServer::start(vec![ + response( + "200 OK", + &[("Content-Type", "application/json")], + &initialize, + ), + response("204 No Content", &[], ""), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + + assert!(matches!( + transport.initialize().await, + Err(StreamableHttpError::InvalidResponse) + )); + assert_eq!(transport.session_id().await, None); + server.finish().await; + + let server = TestServer::start(vec![ + response( + "200 OK", + &[("Content-Type", "application/json")], + &initialize, + ), + response( + "200 OK", + &[("Content-Type", "application/json")], + &json!({ "jsonrpc": "2.0", "method": "notifications/progress" }).to_string(), + ), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + assert!(matches!( + transport.initialize().await, + Err(StreamableHttpError::InvalidResponse) + )); + server.finish().await; +} + +#[tokio::test] +async fn interrupted_sse_resumes_with_last_event_id() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let resumed = "id: event-2\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[]}}\n\n"; + let server = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "resume-session"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + response( + "200 OK", + &[("Content-Type", "text/event-stream")], + "id: event-1\ndata:\n\n", + ), + response("503 Service Unavailable", &[], ""), + response("200 OK", &[("Content-Type", "text/event-stream")], resumed), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + + let page = transport + .list_tools(None, json!(1)) + .await + .expect("resumed list succeeds"); + assert!(page.tools.is_empty()); + let requests = server.finish().await; + for request in &requests[3..=4] { + assert!(request.starts_with("GET /mcp HTTP/1.1")); + assert!( + request + .to_ascii_lowercase() + .contains("last-event-id: event-1") + ); + } +} + +#[tokio::test] +async fn initialize_sse_resume_uses_newly_assigned_session() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let server = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "text/event-stream"), + ("Mcp-Session-Id", "initialize-session"), + ], + "id: initialize-1\ndata:\n\n", + ), + response( + "200 OK", + &[("Content-Type", "text/event-stream")], + &format!("data: {initialize}\n\n"), + ), + response("202 Accepted", &[], ""), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + + transport + .initialize() + .await + .expect("stateful initialize resumes"); + let requests = server.finish().await; + let resume = requests[1].to_ascii_lowercase(); + assert!(resume.starts_with("get /mcp http/1.1")); + assert!(resume.contains("mcp-session-id: initialize-session")); + assert!(resume.contains("last-event-id: initialize-1")); + let initialized = requests[2].to_ascii_lowercase(); + assert!(initialized.starts_with("post /mcp http/1.1")); + assert!(initialized.contains("mcp-session-id: initialize-session")); + assert!(initialized.contains("mcp-protocol-version: 2025-11-25")); +} + +#[tokio::test] +async fn stateless_sse_also_resumes_with_last_event_id() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let server = TestServer::start(vec![ + response( + "200 OK", + &[("Content-Type", "application/json")], + &initialize, + ), + response("202 Accepted", &[], ""), + response( + "200 OK", + &[("Content-Type", "text/event-stream")], + "id: stateless-1\ndata:\n\n", + ), + response( + "200 OK", + &[("Content-Type", "text/event-stream")], + "data: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[]}}\n\n", + ), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + transport + .list_tools(None, json!(2)) + .await + .expect("stateless resume succeeds"); + + let requests = server.finish().await; + let resumed = requests[3].to_ascii_lowercase(); + assert!(resumed.starts_with("get /mcp http/1.1")); + assert!(resumed.contains("last-event-id: stateless-1")); + assert!(!resumed.contains("mcp-session-id:")); +} + +#[tokio::test] +async fn matching_sse_response_returns_before_stream_eof() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let endpoint = format!( + "http://{}/mcp", + listener.local_addr().expect("listener has an address") + ); + let task = tokio::spawn(async move { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + for response in [ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "stream-session"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + ] { + let (mut stream, _) = listener.accept().await.expect("request is accepted"); + let mut request = vec![0_u8; 8192]; + let read = stream.read(&mut request).await.expect("request is read"); + assert!(read > 0, "request is not empty"); + stream + .write_all(response.as_bytes()) + .await + .expect("response is written"); + } + let (mut stream, _) = listener.accept().await.expect("tool request is accepted"); + let mut request = vec![0_u8; 8192]; + let read = stream + .read(&mut request) + .await + .expect("tool request is read"); + assert!(read > 0, "tool request is not empty"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\nid: result-1\ndata: {\"jsonrpc\":\"2.0\",\"id\":9,\"result\":{\"content\":[]}}\n\n", + ) + .await + .expect("stream event is written"); + stream.flush().await.expect("stream event is flushed"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + let transport = make_transport(endpoint); + transport + .initialize() + .await + .expect("initialization succeeds"); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(500), + transport.call_tool("ping", json!({}), json!(9)), + ) + .await + .expect("matching response does not wait for EOF") + .expect("tool call succeeds"); + assert_eq!(result, json!({ "content": [] })); + task.abort(); +} + +#[tokio::test] +async fn notification_get_stream_signals_tool_list_changes() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let notification = "id: changed-1\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n"; + let server = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "notify-session"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + response( + "200 OK", + &[("Content-Type", "text/event-stream")], + notification, + ), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + let mut changes = transport.subscribe_tool_list_changed(); + let listener_transport = transport.clone(); + let listener = tokio::spawn(async move { listener_transport.listen_notifications().await }); + + tokio::time::timeout(std::time::Duration::from_secs(1), changes.recv()) + .await + .expect("change signal arrives before timeout") + .expect("change channel remains open"); + let requests = server.finish().await; + assert!(requests[2].starts_with("GET /mcp HTTP/1.1")); + listener.abort(); +} + +#[test] +fn sse_parser_handles_multiline_data_and_rejects_empty_streams() { + let body = b"id: prime\ndata:\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\ndata: \"id\":1,\"result\":{}}\n\n"; + let parsed = parse_response_messages(&OutboundResponse { + status: reqwest::StatusCode::OK, + headers: [( + reqwest::header::CONTENT_TYPE, + "text/event-stream".parse().unwrap(), + )] + .into_iter() + .collect(), + body: body.to_vec(), + final_url: "https://example.com/mcp".parse().unwrap(), + }) + .expect("multiline SSE data parses"); + assert_eq!( + parsed, + vec![json!({ "jsonrpc": "2.0", "id": 1, "result": {} })] + ); + + let empty = parse_response_messages(&OutboundResponse { + status: reqwest::StatusCode::OK, + headers: [( + reqwest::header::CONTENT_TYPE, + "text/event-stream".parse().unwrap(), + )] + .into_iter() + .collect(), + body: b": keepalive\n\n".to_vec(), + final_url: "https://example.com/mcp".parse().unwrap(), + }); + assert!(matches!(empty, Err(StreamableHttpError::InvalidResponse))); +} + +#[test] +fn public_networks_are_the_default_and_protocol_headers_are_reserved() { + let denied = + StreamableHttpTransport::new(StreamableHttpConfig::new("http://127.0.0.1:1234/mcp")); + assert!(matches!( + denied, + Err(StreamableHttpError::Outbound( + crate::outbound::OutboundError::PrivateAddress + )) + )); + + let mut config = StreamableHttpConfig::new("https://example.com/mcp"); + config + .headers + .insert("mcp-session-id", "injected".parse().unwrap()); + assert!(matches!( + StreamableHttpTransport::new(config), + Err(StreamableHttpError::ReservedHeader) + )); + + let mut config = StreamableHttpConfig::new("https://example.com/mcp"); + config + .headers + .insert("last-event-id", "leaked".parse().unwrap()); + assert!(matches!( + StreamableHttpTransport::new(config), + Err(StreamableHttpError::ReservedHeader) + )); +} + +#[test] +fn response_error_data_is_structured_and_sanitizable_by_the_caller() { + let error = super::response_result( + vec![json!({ + "jsonrpc": "2.0", + "id": "call", + "error": { "code": -32602, "message": "bad args", "data": { "secret": true } } + })], + &Value::String("call".to_owned()), + ) + .expect_err("JSON-RPC error is returned"); + assert!(matches!( + error, + StreamableHttpError::JsonRpc { code: -32602, .. } + )); +} + +#[test] +fn outgoing_json_rpc_rejects_fractional_and_ambiguous_responses() { + for message in [ + json!({ "jsonrpc": "2.0", "id": 1.5, "method": "tools/list" }), + json!({ "jsonrpc": "2.0", "result": {} }), + json!({ "jsonrpc": "2.0", "id": 1, "result": {}, "error": { "code": -1, "message": "bad" } }), + ] { + assert!(matches!( + super::validate_outgoing_message(&message), + Err(StreamableHttpError::InvalidRequest) + )); + } +} + +#[tokio::test] +async fn notifications_are_validated_and_never_classified_as_responses() { + let transport = make_transport("http://127.0.0.1:1/mcp".to_owned()); + let mut changes = transport.subscribe_tool_list_changed(); + let generation = transport.lock_state().generation; + assert!(matches!( + transport + .handle_server_message( + &json!({ + "method": "notifications/tools/list_changed", + "result": {} + }), + generation, + ) + .await, + Err(StreamableHttpError::InvalidResponse) + )); + assert!(matches!( + changes.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + assert!( + !transport + .handle_server_message( + &json!({ "jsonrpc": "2.0", "method": "notifications/progress", "params": {} }), + generation, + ) + .await + .expect("valid notification is accepted") + ); +} + +#[test] +fn sse_retry_is_bounded_and_retained_for_reconnect() { + let mut decoder = super::SseDecoder::default(); + assert_eq!(decoder.retry_delay(), std::time::Duration::from_millis(250)); + decoder + .push(b"retry: 60000\nid: one\ndata:\n\n") + .expect("retry event parses"); + assert_eq!(decoder.retry_delay(), std::time::Duration::from_secs(10)); + assert_eq!(decoder.last_event_id(), Some("one")); + decoder + .push(b"retry: 0\nid: two\ndata:\n\n") + .expect("minimum retry event parses"); + assert_eq!(decoder.retry_delay(), std::time::Duration::from_millis(50)); +} + +#[test] +fn unterminated_sse_event_is_discarded_without_advancing_cursor() { + let mut decoder = super::SseDecoder::default(); + let events = decoder + .push(b"id: truncated\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}") + .expect("partial event bytes are buffered"); + assert!(events.is_empty()); + assert!( + decoder + .finish() + .expect("partial event is discarded") + .is_empty() + ); + assert_eq!(decoder.last_event_id(), None); +} + +#[test] +fn sse_accepts_bom_and_all_standard_line_endings_across_chunks() { + for body in [ + b"data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n".as_slice(), + b"data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\r\n\r\n".as_slice(), + b"data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\r\r".as_slice(), + ] { + let parsed = super::parse_sse(body).expect("standard SSE line endings parse"); + assert_eq!(parsed[0]["id"], 1); + } + + let mut decoder = super::SseDecoder::default(); + assert!( + decoder + .push(&[0xef]) + .expect("BOM prefix buffers") + .is_empty() + ); + assert!( + decoder + .push(b"\xbb\xbfdata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}\r") + .expect("split BOM and CR buffer") + .is_empty() + ); + let mut events = decoder.push(b"\r").expect("lone CR boundary parses"); + events.extend(decoder.finish().expect("stream finishes")); + assert_eq!(events[0].message.as_ref().expect("message exists")["id"], 2); +} + +#[test] +fn sse_trickle_and_many_data_lines_remain_linearly_bounded() { + let mut decoder = super::SseDecoder::default(); + for _ in 0..10_000 { + assert!(decoder.push(b"x").expect("trickle byte buffers").is_empty()); + assert_eq!(decoder.scan_offset, decoder.pending.len()); + } + assert!( + decoder + .finish() + .expect("trickle stream finishes") + .is_empty() + ); + + let mut body = Vec::new(); + for _ in 0..10_000 { + body.extend_from_slice(b"data:\n"); + } + body.extend_from_slice(b"data: {\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{}}\n\n"); + let messages = super::parse_sse(&body).expect("many bounded data lines parse"); + assert_eq!(messages[0]["id"], 3); +} + +#[tokio::test] +async fn generation_change_during_retry_delay_prevents_stale_resume_get() { + let initialize = json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { "protocolVersion": "2025-11-25", "capabilities": {} } + }) + .to_string(); + let server = TestServer::start(vec![ + response( + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "retry-session"), + ], + &initialize, + ), + response("202 Accepted", &[], ""), + response( + "200 OK", + &[("Content-Type", "text/event-stream")], + "retry: 100\nid: retry-1\ndata:\n\n", + ), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + transport + .initialize() + .await + .expect("initialization succeeds"); + let listing = { + let transport = transport.clone(); + tokio::spawn(async move { transport.list_tools(None, json!(4)).await }) + }; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + transport.reset_state().await; + + assert!(matches!( + listing.await.expect("listing task completes"), + Err(StreamableHttpError::SessionInvalidated) + )); + let requests = server.finish().await; + assert_eq!(requests.len(), 3); +} + +#[tokio::test] +async fn stale_responses_cannot_restore_an_invalidated_session() { + let transport = make_transport("http://127.0.0.1:1/mcp".to_owned()); + { + let mut state = transport.lock_state(); + state.initialized = true; + state.session_id = Some("old".to_owned()); + } + let generation = transport.lock_state().generation; + transport.reset_state().await; + let response = OutboundResponse { + status: reqwest::StatusCode::OK, + headers: [(super::MCP_SESSION_ID, "old".parse().unwrap())] + .into_iter() + .collect(), + body: Vec::new(), + final_url: "http://127.0.0.1:1/mcp".parse().unwrap(), + }; + + assert!(matches!( + transport + .accept_response_session(&response.headers, generation, false) + .await, + Err(StreamableHttpError::SessionInvalidated) + )); + assert_eq!(transport.session_id().await, None); +} + +#[tokio::test] +async fn only_initialize_can_establish_a_session() { + let transport = make_transport("http://127.0.0.1:1/mcp".to_owned()); + transport.lock_state().initialized = true; + let generation = transport.lock_state().generation; + let response = OutboundResponse { + status: reqwest::StatusCode::OK, + headers: [(super::MCP_SESSION_ID, "late".parse().unwrap())] + .into_iter() + .collect(), + body: Vec::new(), + final_url: "http://127.0.0.1:1/mcp".parse().unwrap(), + }; + + assert!(matches!( + transport + .accept_response_session(&response.headers, generation, false) + .await, + Err(StreamableHttpError::InvalidSessionId) + )); + assert_eq!(transport.session_id().await, None); +} + +#[tokio::test] +async fn invalid_resumed_session_header_invalidates_local_state() { + let transport = make_transport("http://127.0.0.1:1/mcp".to_owned()); + { + let mut state = transport.lock_state(); + state.initialized = true; + state.session_id = Some("expected".to_owned()); + } + let generation = transport.lock_state().generation; + let headers = [(super::MCP_SESSION_ID, "changed".parse().unwrap())] + .into_iter() + .collect(); + + assert!(matches!( + transport + .accept_response_session_or_reset(&headers, generation, false) + .await, + Err(StreamableHttpError::SessionChanged) + )); + assert_eq!(transport.session_id().await, None); + assert!(matches!( + transport.list_tools(None, json!(1)).await, + Err(StreamableHttpError::NotInitialized) + )); +} + +#[tokio::test] +async fn auxiliary_responses_enforce_session_expiry_and_header_pinning() { + for (status, response_headers, expected_changed) in [ + ("404 Not Found", Vec::new(), false), + ("202 Accepted", vec![("Mcp-Session-Id", "different")], true), + ] { + let server = TestServer::start(vec![response(status, &response_headers, "")]).await; + let transport = make_transport(server.endpoint.clone()); + { + let mut state = transport.lock_state(); + state.initialized = true; + state.session_id = Some("expected".to_owned()); + state.negotiated_protocol_version = Some(rmcp::model::ProtocolVersion::V_2025_11_25); + } + let generation = transport.lock_state().generation; + let error = transport + .post_auxiliary_response( + json!({ "jsonrpc": "2.0", "id": "server", "result": {} }), + generation, + ) + .await + .expect_err("invalid auxiliary session response is rejected"); + if expected_changed { + assert!(matches!(error, StreamableHttpError::SessionChanged)); + } else { + assert!(matches!(error, StreamableHttpError::SessionExpired)); + } + assert_eq!(transport.session_id().await, None); + server.finish().await; + } +} + +#[tokio::test] +async fn auxiliary_headers_invalidate_session_before_stalled_or_oversized_body() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let endpoint = format!( + "http://{}/mcp", + listener.local_addr().expect("listener has an address") + ); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("auxiliary POST accepts"); + let mut request = vec![0_u8; 8192]; + let read = stream + .read(&mut request) + .await + .expect("auxiliary POST reads"); + assert!(read > 0, "auxiliary request is not empty"); + stream + .write_all(b"HTTP/1.1 404 Not Found\r\n\r\n") + .await + .expect("stalled 404 headers write"); + stream.flush().await.expect("stalled 404 headers flush"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + let transport = make_transport(endpoint); + { + let mut state = transport.lock_state(); + state.initialized = true; + state.session_id = Some("expected".to_owned()); + state.negotiated_protocol_version = Some(rmcp::model::ProtocolVersion::V_2025_11_25); + } + let generation = transport.lock_state().generation; + let error = tokio::time::timeout( + std::time::Duration::from_millis(300), + transport.post_auxiliary_response( + json!({ "jsonrpc": "2.0", "id": "server", "result": {} }), + generation, + ), + ) + .await + .expect("404 invalidates before stalled body") + .expect_err("stalled 404 expires session"); + assert!(matches!(error, StreamableHttpError::SessionExpired)); + assert_eq!(transport.session_id().await, None); + server.abort(); + + let server = TestServer::start(vec![ + "HTTP/1.1 202 Accepted\r\nMcp-Session-Id: changed\r\nContent-Length: 16777217\r\n\r\n" + .to_owned(), + ]) + .await; + let transport = make_transport(server.endpoint.clone()); + { + let mut state = transport.lock_state(); + state.initialized = true; + state.session_id = Some("expected".to_owned()); + state.negotiated_protocol_version = Some(rmcp::model::ProtocolVersion::V_2025_11_25); + } + let generation = transport.lock_state().generation; + assert!(matches!( + transport + .post_auxiliary_response( + json!({ "jsonrpc": "2.0", "id": "server", "result": {} }), + generation, + ) + .await, + Err(StreamableHttpError::SessionChanged) + )); + assert_eq!(transport.session_id().await, None); + server.finish().await; +} diff --git a/src/mcp/upstream/mod.rs b/src/mcp/upstream/mod.rs new file mode 100644 index 000000000..e6c514207 --- /dev/null +++ b/src/mcp/upstream/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod stdio; diff --git a/src/mcp/upstream/stdio/mod.rs b/src/mcp/upstream/stdio/mod.rs new file mode 100644 index 000000000..3b4352be3 --- /dev/null +++ b/src/mcp/upstream/stdio/mod.rs @@ -0,0 +1,2784 @@ +use std::{ + collections::{BTreeMap, HashMap}, + future::pending, + io, + io::Read as _, + path::{Path, PathBuf}, + process::{ExitStatus, Stdio}, + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; + +use rmcp::model::ProtocolVersion; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}, + process::{Child, ChildStdout, Command}, + sync::{OwnedSemaphorePermit, Semaphore, broadcast, mpsc, oneshot}, + task::JoinHandle, + time::{Instant, timeout, timeout_at}, +}; + +const MAX_TEMPLATE_NAME_BYTES: usize = 64; +const MAX_ARGUMENTS: usize = 128; +const MAX_ENVIRONMENT_ENTRIES: usize = 128; +const MAX_ARGUMENT_BYTES: usize = 8 * 1024; +const MAX_ENVIRONMENT_VALUE_BYTES: usize = 16 * 1024; +const MAX_TEMPLATE_BYTES: usize = 128 * 1024; +const MAX_TEMPLATES: usize = 256; +const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024; +const TOOL_LIST_CHANGED_CAPACITY: usize = 64; +const LIFECYCLE_STATE_MASK: u64 = 0b11; +const LIFECYCLE_UNINITIALIZED: u64 = 0; +const LIFECYCLE_INITIALIZING: u64 = 1; +const LIFECYCLE_INITIALIZED: u64 = 2; +const MAX_LIFECYCLE_GENERATION: u64 = u64::MAX >> 2; +pub const DEFAULT_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V_2025_11_25; + +fn lifecycle_word(generation: u64, state: u64) -> u64 { + ((generation & MAX_LIFECYCLE_GENERATION) << 2) | (state & LIFECYCLE_STATE_MASK) +} + +fn lifecycle_state(word: u64) -> u64 { + word & LIFECYCLE_STATE_MASK +} + +fn lifecycle_generation(word: u64) -> u64 { + word >> 2 +} + +fn acquire_lifecycle_generation(lifecycle: &AtomicU64) -> Result { + let mut current = lifecycle.load(Ordering::Acquire); + loop { + if lifecycle_state(current) != LIFECYCLE_UNINITIALIZED { + return Err(StdioTransportError::AlreadyInitialized); + } + let generation = + (lifecycle_generation(current).wrapping_add(1) & MAX_LIFECYCLE_GENERATION).max(1); + let next = lifecycle_word(generation, LIFECYCLE_INITIALIZING); + match lifecycle.compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Acquire) { + Ok(_) => return Ok(generation), + Err(observed) => current = observed, + } + } +} + +fn reset_lifecycle_generation(lifecycle: &AtomicU64, generation: u64) { + let _ = lifecycle.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + (lifecycle_generation(current) == generation) + .then(|| lifecycle_word(generation, LIFECYCLE_UNINITIALIZED)) + }); +} + +fn reset_lifecycle_current(lifecycle: &AtomicU64) { + let _ = lifecycle.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + Some(lifecycle_word( + lifecycle_generation(current), + LIFECYCLE_UNINITIALIZED, + )) + }); +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StdioTemplate { + pub name: String, + pub executable: PathBuf, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub arguments: Vec, + #[serde(default)] + pub environment: BTreeMap, + #[serde(default)] + pub secret_environment: Vec, +} + +#[derive(Clone)] +pub(crate) struct TrustedStdioTemplate { + name: String, + executable: PathBuf, + cwd: Option, + arguments: Vec, + environment: BTreeMap, + secret_environment: Vec, +} + +impl std::fmt::Debug for TrustedStdioTemplate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TrustedStdioTemplate") + .field("name", &self.name) + .field("secret_environment", &self.secret_environment) + .finish_non_exhaustive() + } +} + +impl TrustedStdioTemplate { + pub(crate) fn validate(template: StdioTemplate) -> Result { + validate_template_name(&template.name)?; + if !template.executable.is_absolute() { + return Err(StdioTemplateError::ExecutableMustBeAbsolute); + } + let executable = std::fs::canonicalize(&template.executable) + .map_err(|_| StdioTemplateError::ExecutableUnavailable)?; + let metadata = std::fs::metadata(&executable) + .map_err(|_| StdioTemplateError::ExecutableUnavailable)?; + if !metadata.is_file() { + return Err(StdioTemplateError::ExecutableUnavailable); + } + validate_executable_permissions(&metadata)?; + let cwd = match template.cwd { + Some(cwd) => { + if !cwd.is_absolute() { + return Err(StdioTemplateError::CwdMustBeAbsolute); + } + let cwd = + std::fs::canonicalize(cwd).map_err(|_| StdioTemplateError::CwdUnavailable)?; + if !std::fs::metadata(&cwd) + .map_err(|_| StdioTemplateError::CwdUnavailable)? + .is_dir() + { + return Err(StdioTemplateError::CwdUnavailable); + } + Some(cwd) + } + None => None, + }; + validate_arguments(&template.arguments)?; + validate_environment(&template.environment)?; + validate_secret_environment(&template.secret_environment, &template.environment)?; + + Ok(Self { + name: template.name, + executable, + cwd, + arguments: template.arguments, + environment: template.environment, + secret_environment: template.secret_environment, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StdioTemplateDescriptor { + pub name: String, + pub secret_fields: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct StdioTemplateRegistry { + templates: BTreeMap, +} + +impl StdioTemplateRegistry { + pub fn new(templates: Vec) -> Result { + if templates.len() > MAX_TEMPLATES { + return Err(StdioTemplateError::TooManyTemplates); + } + let mut registry = Self::default(); + for template in templates { + let template = TrustedStdioTemplate::validate(template)?; + if registry + .templates + .insert(template.name.clone(), template) + .is_some() + { + return Err(StdioTemplateError::DuplicateName); + } + } + Ok(registry) + } + + pub fn load(path: impl AsRef) -> Result { + let file = std::fs::File::open(path).map_err(|_| StdioTemplateError::ConfigRead)?; + if file + .metadata() + .map_err(|_| StdioTemplateError::ConfigRead)? + .len() + > MAX_CONFIG_FILE_BYTES + { + return Err(StdioTemplateError::ConfigTooLarge); + } + let mut bytes = Vec::new(); + file.take(MAX_CONFIG_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| StdioTemplateError::ConfigRead)?; + if bytes.len() as u64 > MAX_CONFIG_FILE_BYTES { + return Err(StdioTemplateError::ConfigTooLarge); + } + let config: StdioTemplateFile = + serde_json::from_slice(&bytes).map_err(|_| StdioTemplateError::ConfigInvalid)?; + Self::new(config.templates) + } + + pub(crate) fn template(&self, name: &str) -> Result<&TrustedStdioTemplate, StdioTemplateError> { + self.templates + .get(name) + .ok_or(StdioTemplateError::UnknownTemplate) + } + + pub fn descriptors(&self) -> Vec { + self.templates + .values() + .map(|template| StdioTemplateDescriptor { + name: template.name.clone(), + secret_fields: template.secret_environment.clone(), + }) + .collect() + } + + pub async fn connect_with_secrets( + &self, + name: &str, + secrets: &BTreeMap, + limits: StdioTransportLimits, + ) -> Result { + let mut template = self.template(name)?.clone(); + validate_secret_overlay(&template.secret_environment, secrets)?; + template.environment.extend(secrets.clone()); + validate_environment(&template.environment)?; + StdioClient::connect(template, limits).await + } +} + +#[derive(Debug, Error)] +pub enum StdioTemplateError { + #[error("the stdio template configuration could not be read")] + ConfigRead, + #[error("the stdio template configuration is too large")] + ConfigTooLarge, + #[error("the stdio template configuration is invalid")] + ConfigInvalid, + #[error("the stdio template configuration has too many templates")] + TooManyTemplates, + #[error("the stdio template name is invalid")] + InvalidName, + #[error("stdio template names must be unique")] + DuplicateName, + #[error("the named stdio template does not exist")] + UnknownTemplate, + #[error("the stdio executable must be an absolute path")] + ExecutableMustBeAbsolute, + #[error("the stdio executable is unavailable")] + ExecutableUnavailable, + #[error("the stdio executable is not executable")] + ExecutableNotExecutable, + #[error("the stdio working directory must be an absolute path")] + CwdMustBeAbsolute, + #[error("the stdio working directory is unavailable")] + CwdUnavailable, + #[error("the stdio template has too many arguments")] + TooManyArguments, + #[error("a stdio template argument is too large")] + ArgumentTooLarge, + #[error("the stdio template has too many environment entries")] + TooManyEnvironmentEntries, + #[error("a stdio template environment entry is invalid")] + InvalidEnvironmentEntry, + #[error("the stdio template is too large")] + TemplateTooLarge, + #[error("the stdio template secret fields are invalid")] + InvalidSecretFields, + #[error("the supplied stdio template secrets do not match the approved fields")] + InvalidSecretOverlay, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StdioTemplateFile { + templates: Vec, +} + +#[derive(Clone, Copy, Debug)] +pub struct StdioTransportLimits { + pub max_message_bytes: usize, + pub max_stderr_bytes: usize, + pub request_timeout: Duration, + pub shutdown_grace: Duration, + pub initial_restart_backoff: Duration, + pub max_restart_backoff: Duration, + pub command_queue_capacity: usize, +} + +impl Default for StdioTransportLimits { + fn default() -> Self { + Self { + max_message_bytes: 16 * 1024 * 1024, + max_stderr_bytes: 64 * 1024, + request_timeout: Duration::from_secs(60), + shutdown_grace: Duration::from_secs(2), + initial_restart_backoff: Duration::from_millis(100), + max_restart_backoff: Duration::from_secs(5), + command_queue_capacity: 256, + } + } +} + +impl StdioTransportLimits { + fn validate(self) -> Result { + if self.max_message_bytes == 0 + || self.max_message_bytes > 32 * 1024 * 1024 + || self.max_stderr_bytes == 0 + || self.max_stderr_bytes > 1024 * 1024 + || self.request_timeout.is_zero() + || self.request_timeout > Duration::from_secs(5 * 60) + || self.shutdown_grace.is_zero() + || self.shutdown_grace > Duration::from_secs(30) + || self.initial_restart_backoff.is_zero() + || self.initial_restart_backoff > Duration::from_secs(30) + || self.max_restart_backoff < self.initial_restart_backoff + || self.max_restart_backoff > Duration::from_secs(30) + || self.request_timeout <= self.max_restart_backoff + || self.command_queue_capacity == 0 + || self.command_queue_capacity > 4_096 + { + return Err(StdioTransportError::InvalidLimits); + } + Ok(self) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpServerInfo { + pub name: String, + pub version: String, + #[serde(default)] + pub title: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: ProtocolVersion, + #[serde(default)] + pub capabilities: Value, + #[serde(default)] + pub server_info: Option, + #[serde(default)] + pub instructions: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpTool { + pub name: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub input_schema: Value, + #[serde(default)] + pub output_schema: Option, + #[serde(default)] + pub annotations: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListToolsResult { + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CallToolResult { + #[serde(default)] + pub content: Vec, + #[serde(default)] + pub structured_content: Option, + #[serde(default)] + pub is_error: bool, +} + +#[derive(Debug, Error)] +pub enum StdioTransportError { + #[error(transparent)] + Template(#[from] StdioTemplateError), + #[error("the stdio transport limits are invalid")] + InvalidLimits, + #[error("the stdio process could not be started")] + Spawn, + #[error("the stdio transport command queue is closed")] + Closed, + #[error("the stdio transport must be initialized again after restart")] + RestartRequiresInitialization, + #[error("the stdio transport has not been initialized")] + NotInitialized, + #[error("the stdio transport is already initialized")] + AlreadyInitialized, + #[error("the stdio request timed out")] + Timeout, + #[error("the stdio request is too large")] + RequestTooLarge, + #[error("the stdio response is too large")] + ResponseTooLarge, + #[error("the stdio server returned an invalid JSON-RPC message")] + InvalidResponse, + #[error("the stdio server returned JSON-RPC error code {code}")] + JsonRpc { code: i64 }, + #[error("the stdio process exited unexpectedly")] + ProcessExited { + code: Option, + stderr_truncated: bool, + }, + #[error("the tool call outcome is unknown because the stdio transport disconnected")] + AmbiguousToolCall, + #[error("the stdio request ID is already in flight")] + DuplicateRequestId, + #[error("the stdio transport has too many requests in flight")] + TooManyInFlight, + #[error("the stdio response did not match an in-flight request")] + UnexpectedResponseId, + #[error("the stdio request could not be encoded")] + Encode, + #[error("the stdio response could not be decoded")] + Decode, + #[error("the stdio request is invalid")] + InvalidRequest, + #[error("the stdio server selected an unsupported protocol version")] + ProtocolVersionMismatch, +} + +pub struct StdioClient { + commands: mpsc::Sender, + tool_list_changed: broadcast::Sender<()>, + queue_bytes: std::sync::Arc, + lifecycle: std::sync::Arc, + actor: Option>, + next_request_id: AtomicU64, + request_timeout: Duration, + max_message_bytes: usize, +} + +#[derive(Clone)] +pub(crate) struct StdioLifecycleMonitor { + lifecycle: std::sync::Arc, +} + +impl StdioLifecycleMonitor { + pub(crate) fn is_initialized(&self) -> bool { + lifecycle_state(self.lifecycle.load(Ordering::Acquire)) == LIFECYCLE_INITIALIZED + } + + #[cfg(test)] + pub(crate) fn initialized_fixture() -> Self { + Self { + lifecycle: std::sync::Arc::new(AtomicU64::new(lifecycle_word( + 1, + LIFECYCLE_INITIALIZED, + ))), + } + } + + #[cfg(test)] + pub(crate) fn disconnect_fixture(&self) { + reset_lifecycle_current(&self.lifecycle); + } +} + +impl StdioClient { + pub(crate) async fn connect( + template: TrustedStdioTemplate, + limits: StdioTransportLimits, + ) -> Result { + let limits = limits.validate()?; + let process = RunningProcess::spawn(&template, limits).await?; + let (commands, receiver) = mpsc::channel(limits.command_queue_capacity); + let (tool_list_changed, _) = broadcast::channel(TOOL_LIST_CHANGED_CAPACITY); + let queue_bytes = + std::sync::Arc::new(Semaphore::new(limits.max_message_bytes.saturating_mul(2))); + let lifecycle = + std::sync::Arc::new(AtomicU64::new(lifecycle_word(0, LIFECYCLE_UNINITIALIZED))); + let actor = tokio::spawn(run_actor( + template, + limits, + process, + receiver, + tool_list_changed.clone(), + lifecycle.clone(), + )); + Ok(Self { + commands, + tool_list_changed, + queue_bytes, + lifecycle, + actor: Some(actor), + next_request_id: AtomicU64::new(1), + request_timeout: limits.request_timeout, + max_message_bytes: limits.max_message_bytes, + }) + } + + pub async fn initialize( + &self, + protocol_version: ProtocolVersion, + client_info: Value, + capabilities: Value, + ) -> Result { + let generation = acquire_lifecycle_generation(&self.lifecycle)?; + let mut guard = + InitializationGuard::new(self.lifecycle.clone(), self.commands.clone(), generation); + let result = self + .initialize_inner(protocol_version, client_info, capabilities, generation) + .await; + if result.is_err() { + self.reset_transport(generation).await; + } + match result { + Ok(initialized) => { + self.lifecycle + .compare_exchange( + lifecycle_word(generation, LIFECYCLE_INITIALIZING), + lifecycle_word(generation, LIFECYCLE_INITIALIZED), + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| StdioTransportError::Closed)?; + guard.disarm(); + let _ = self + .commands + .try_send(ClientCommand::Healthy { generation }); + Ok(initialized) + } + Err(error) => { + if lifecycle_state(self.lifecycle.load(Ordering::Acquire)) + == LIFECYCLE_UNINITIALIZED + { + guard.disarm(); + } + Err(error) + } + } + } + + async fn initialize_inner( + &self, + protocol_version: ProtocolVersion, + client_info: Value, + capabilities: Value, + generation: u64, + ) -> Result { + if protocol_version != DEFAULT_PROTOCOL_VERSION + || !client_info.is_object() + || !capabilities.is_object() + { + return Err(StdioTransportError::InvalidRequest); + } + let result = self + .request( + self.next_id(), + "initialize", + json!({ + "protocolVersion": protocol_version, + "clientInfo": client_info, + "capabilities": capabilities, + }), + RequestSemantics::Replayable, + Some(generation), + ) + .await?; + let initialized: InitializeResult = + serde_json::from_value(result).map_err(|_| StdioTransportError::Decode)?; + if initialized.protocol_version != DEFAULT_PROTOCOL_VERSION { + return Err(StdioTransportError::ProtocolVersionMismatch); + } + if !initialized.capabilities.is_object() { + return Err(StdioTransportError::Decode); + } + self.notify("notifications/initialized", json!({}), Some(generation)) + .await?; + Ok(initialized) + } + + pub async fn list_tools( + &self, + cursor: Option<&str>, + ) -> Result { + self.require_initialized()?; + if cursor.is_some_and(|cursor| cursor.len() > 16 * 1024) { + return Err(StdioTransportError::InvalidRequest); + } + let params = cursor.map_or_else(|| json!({}), |cursor| json!({ "cursor": cursor })); + let result = self + .request( + self.next_id(), + "tools/list", + params, + RequestSemantics::Replayable, + None, + ) + .await?; + let result: ListToolsResult = + serde_json::from_value(result).map_err(|_| StdioTransportError::Decode)?; + if result.tools.iter().any(|tool| { + tool.name.is_empty() || tool.name.len() > 1_024 || !tool.input_schema.is_object() + }) { + return Err(StdioTransportError::Decode); + } + Ok(result) + } + + pub async fn call_tool( + &self, + name: &str, + arguments: Value, + request_id: u64, + ) -> Result { + self.require_initialized()?; + if name.is_empty() || name.len() > 1_024 || !arguments.is_object() { + return Err(StdioTransportError::InvalidRequest); + } + let result = self + .request( + request_id, + "tools/call", + json!({ "name": name, "arguments": arguments }), + RequestSemantics::AmbiguousAfterWrite, + None, + ) + .await?; + let result: CallToolResult = + serde_json::from_value(result).map_err(|_| StdioTransportError::Decode)?; + if result.content.iter().any(|content| !content.is_object()) { + return Err(StdioTransportError::Decode); + } + Ok(result) + } + + fn require_initialized(&self) -> Result<(), StdioTransportError> { + if lifecycle_state(self.lifecycle.load(Ordering::Acquire)) == LIFECYCLE_INITIALIZED { + Ok(()) + } else { + Err(StdioTransportError::NotInitialized) + } + } + + pub fn subscribe_tool_list_changed(&self) -> broadcast::Receiver<()> { + self.tool_list_changed.subscribe() + } + + #[cfg(test)] + pub(crate) fn is_initialized(&self) -> bool { + lifecycle_state(self.lifecycle.load(Ordering::Acquire)) == LIFECYCLE_INITIALIZED + } + + pub(crate) fn lifecycle_monitor(&self) -> StdioLifecycleMonitor { + StdioLifecycleMonitor { + lifecycle: self.lifecycle.clone(), + } + } + + pub async fn shutdown(mut self) { + let (completed, completion) = oneshot::channel(); + let _ = self + .commands + .send(ClientCommand::Shutdown { + completed: Some(completed), + }) + .await; + let _ = completion.await; + if let Some(actor) = self.actor.take() { + let _ = actor.await; + } + } + + fn next_id(&self) -> u64 { + self.next_request_id.fetch_add(1, Ordering::Relaxed) + } + + async fn request( + &self, + id: u64, + method: &str, + params: Value, + semantics: RequestSemantics, + generation: Option, + ) -> Result { + let payload = encode_message( + &json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }), + self.max_message_bytes, + )?; + let deadline = Instant::now() + self.request_timeout; + let permit = timeout_at( + deadline, + self.queue_bytes + .clone() + .acquire_many_owned(payload.len().max(1) as u32), + ) + .await + .map_err(|_| StdioTransportError::Timeout)? + .map_err(|_| StdioTransportError::Closed)?; + let (reply, response) = oneshot::channel(); + timeout_at( + deadline, + self.commands.send(ClientCommand::Request { + id, + payload, + permit, + initialization: method == "initialize", + generation, + semantics, + deadline, + reply, + }), + ) + .await + .map_err(|_| StdioTransportError::Timeout)? + .map_err(|_| StdioTransportError::Closed)?; + match timeout_at(deadline, response).await { + Ok(response) => response.map_err(|_| StdioTransportError::Closed)?, + Err(_) => Err(match semantics { + RequestSemantics::Replayable => StdioTransportError::Timeout, + RequestSemantics::AmbiguousAfterWrite => StdioTransportError::AmbiguousToolCall, + }), + } + } + + async fn notify( + &self, + method: &str, + params: Value, + generation: Option, + ) -> Result<(), StdioTransportError> { + let payload = encode_message( + &json!({ "jsonrpc": "2.0", "method": method, "params": params }), + self.max_message_bytes, + )?; + let deadline = Instant::now() + self.request_timeout; + let permit = timeout_at( + deadline, + self.queue_bytes + .clone() + .acquire_many_owned(payload.len().max(1) as u32), + ) + .await + .map_err(|_| StdioTransportError::Timeout)? + .map_err(|_| StdioTransportError::Closed)?; + let (reply, response) = oneshot::channel(); + timeout_at( + deadline, + self.commands.send(ClientCommand::Notification { + payload, + permit, + deadline, + generation, + reply, + }), + ) + .await + .map_err(|_| StdioTransportError::Timeout)? + .map_err(|_| StdioTransportError::Closed)?; + match timeout_at(deadline, response).await { + Ok(response) => response.map_err(|_| StdioTransportError::Closed)?, + Err(_) => Err(StdioTransportError::Timeout), + } + } + + async fn reset_transport(&self, generation: u64) { + let (completed, completion) = oneshot::channel(); + let deadline = Instant::now() + self.request_timeout; + let sent = timeout_at( + deadline, + self.commands.send(ClientCommand::Reset { + generation, + completed, + }), + ) + .await; + if matches!(sent, Ok(Ok(()))) { + let _ = timeout_at(deadline, completion).await; + } else { + reset_lifecycle_generation(&self.lifecycle, generation); + } + } +} + +impl Drop for StdioClient { + fn drop(&mut self) { + if self.actor.is_none() { + return; + } + let _ = self + .commands + .try_send(ClientCommand::Shutdown { completed: None }); + } +} + +struct InitializationGuard { + lifecycle: std::sync::Arc, + commands: mpsc::Sender, + generation: u64, + armed: bool, +} + +impl InitializationGuard { + fn new( + lifecycle: std::sync::Arc, + commands: mpsc::Sender, + generation: u64, + ) -> Self { + Self { + lifecycle, + commands, + generation, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for InitializationGuard { + fn drop(&mut self) { + if !self.armed + || self.lifecycle.load(Ordering::Acquire) + != lifecycle_word(self.generation, LIFECYCLE_INITIALIZING) + { + return; + } + let lifecycle = self.lifecycle.clone(); + let commands = self.commands.clone(); + let generation = self.generation; + let cleanup = async move { + let (completed, completion) = oneshot::channel(); + if commands + .send(ClientCommand::Reset { + generation, + completed, + }) + .await + .is_err() + { + reset_lifecycle_generation(&lifecycle, generation); + return; + } + if completion.await.is_err() { + reset_lifecycle_generation(&lifecycle, generation); + } + }; + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(cleanup); + } else { + reset_lifecycle_generation(&self.lifecycle, self.generation); + } + } +} + +#[derive(Clone, Copy)] +enum RequestSemantics { + Replayable, + AmbiguousAfterWrite, +} + +enum ClientCommand { + Request { + id: u64, + payload: Vec, + permit: OwnedSemaphorePermit, + initialization: bool, + generation: Option, + semantics: RequestSemantics, + deadline: Instant, + reply: oneshot::Sender>, + }, + Notification { + payload: Vec, + permit: OwnedSemaphorePermit, + deadline: Instant, + generation: Option, + reply: oneshot::Sender>, + }, + Reset { + generation: u64, + completed: oneshot::Sender<()>, + }, + Healthy { + generation: u64, + }, + Shutdown { + completed: Option>, + }, +} + +struct PendingRequest { + semantics: RequestSemantics, + generation: Option, + deadline: Instant, + reply: oneshot::Sender>, +} + +async fn run_actor( + template: TrustedStdioTemplate, + limits: StdioTransportLimits, + initial_process: RunningProcess, + mut commands: mpsc::Receiver, + tool_list_changed: broadcast::Sender<()>, + lifecycle: std::sync::Arc, +) { + let mut process = Some(initial_process); + let mut pending = HashMap::::new(); + let mut restart = RestartState::default(); + + 'actor: loop { + if process.is_none() { + let Some(command) = commands.recv().await else { + break; + }; + if let ClientCommand::Shutdown { completed } = command { + if let Some(completed) = completed { + let _ = completed.send(()); + } + break; + } + if let ClientCommand::Reset { + generation, + completed, + } = command + { + reset_lifecycle_generation(&lifecycle, generation); + let _ = completed.send(()); + continue; + } + if matches!(&command, ClientCommand::Healthy { .. }) { + continue; + } + if !matches!( + &command, + ClientCommand::Request { + initialization: true, + .. + } + ) { + reject_command(command, StdioTransportError::RestartRequiresInitialization); + continue; + } + let valid_generation = matches!( + &command, + ClientCommand::Request { + generation: Some(generation), + .. + } if lifecycle.load(Ordering::Acquire) + == lifecycle_word(*generation, LIFECYCLE_INITIALIZING) + ); + if !valid_generation { + reject_command(command, StdioTransportError::Closed); + continue; + } + if let Some(retry_at) = restart.retry_at { + while Instant::now() < retry_at { + tokio::select! { + _ = tokio::time::sleep_until(retry_at) => break, + next = commands.recv() => match next { + Some(ClientCommand::Shutdown { completed }) => { + reject_command(command, StdioTransportError::Closed); + if let Some(completed) = completed { + let _ = completed.send(()); + } + break 'actor; + } + Some(ClientCommand::Reset { generation, completed }) => { + reset_lifecycle_generation(&lifecycle, generation); + let _ = completed.send(()); + } + Some(ClientCommand::Healthy { .. }) => {} + Some(other) => reject_command( + other, + StdioTransportError::RestartRequiresInitialization, + ), + None => { + reject_command(command, StdioTransportError::Closed); + break 'actor; + } + } + } + } + } + if command_reply_closed(&command) { + continue; + } + match RunningProcess::spawn(&template, limits).await { + Ok(spawned) => process = Some(spawned), + Err(error) => { + reject_command(command, error); + restart.record_failure(limits); + continue; + } + } + if let Some(running) = process.as_mut() + && dispatch_command(running, command, &mut pending, &lifecycle, limits) + .await + .is_err() + { + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + } + continue; + } + + let running = process.as_mut().expect("process presence was checked"); + tokio::select! { + command = commands.recv() => { + let Some(command) = command else { + let _ = running.stop(limits).await; + break; + }; + if let ClientCommand::Shutdown { completed } = command { + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + if let Some(completed) = completed { + let _ = completed.send(()); + } + break; + } + if let ClientCommand::Reset { + generation, + completed, + } = command + { + if lifecycle_generation(lifecycle.load(Ordering::Acquire)) != generation { + let _ = completed.send(()); + continue; + } + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + let _ = completed.send(()); + continue; + } + if let ClientCommand::Healthy { generation } = command { + if lifecycle.load(Ordering::Acquire) + == lifecycle_word(generation, LIFECYCLE_INITIALIZED) + { + restart.record_success(); + } + continue; + } + if dispatch_command( + running, + command, + &mut pending, + &lifecycle, + limits, + ) + .await + .is_err() + { + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + } + } + message = running.stdout.read_message(limits.max_message_bytes) => { + match message { + Ok(Some(message)) => { + if handle_server_message( + message, + running, + &mut pending, + &tool_list_changed, + &lifecycle, + limits, + ) + .await + .is_err() + { + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + } + } + Ok(None) | Err(_) => { + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + } + } + } + status = running.child.wait() => { + while let Ok(Ok(Some(message))) = timeout( + Duration::from_millis(10), + running.stdout.read_message(limits.max_message_bytes), + ) + .await + { + if handle_server_message( + message, + running, + &mut pending, + &tool_list_changed, + &lifecycle, + limits, + ) + .await + .is_err() + { + break; + } + } + let error = running.after_exit(status.ok(), limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + } + _ = wait_for_pending_deadline(&pending) => { + if expire_pending(&mut pending) { + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + } + } + _ = running.writer_failed.recv() => { + let error = running.stop(limits).await; + fail_pending(&mut pending, error); + reset_lifecycle_current(&lifecycle); + process = None; + restart.record_failure(limits); + } + } + } +} + +async fn wait_for_pending_deadline(pending_requests: &HashMap) { + if let Some(deadline) = pending_requests + .values() + .map(|request| request.deadline) + .min() + { + tokio::time::sleep_until(deadline).await; + } else { + pending::<()>().await; + } +} + +fn expire_pending(pending_requests: &mut HashMap) -> bool { + let now = Instant::now(); + let expired = pending_requests + .iter() + .filter_map(|(id, request)| (request.deadline <= now).then_some(*id)) + .collect::>(); + let had_expired = !expired.is_empty(); + for id in expired { + let Some(request) = pending_requests.remove(&id) else { + continue; + }; + let error = match request.semantics { + RequestSemantics::Replayable => StdioTransportError::Timeout, + RequestSemantics::AmbiguousAfterWrite => StdioTransportError::AmbiguousToolCall, + }; + let _ = request.reply.send(Err(error)); + } + had_expired +} + +async fn dispatch_command( + process: &mut RunningProcess, + command: ClientCommand, + pending: &mut HashMap, + lifecycle: &AtomicU64, + limits: StdioTransportLimits, +) -> Result<(), StdioTransportError> { + match command { + ClientCommand::Request { + id, + payload, + permit, + initialization, + generation, + semantics, + deadline, + reply, + } => { + if initialization + && !generation.is_some_and(|generation| { + lifecycle.load(Ordering::Acquire) + == lifecycle_word(generation, LIFECYCLE_INITIALIZING) + }) + { + let _ = reply.send(Err(StdioTransportError::Closed)); + return Ok(()); + } + if Instant::now() >= deadline { + let error = match semantics { + RequestSemantics::Replayable => StdioTransportError::Timeout, + RequestSemantics::AmbiguousAfterWrite => StdioTransportError::AmbiguousToolCall, + }; + let _ = reply.send(Err(error)); + return Ok(()); + } + if pending.len() >= limits.command_queue_capacity { + let _ = reply.send(Err(StdioTransportError::TooManyInFlight)); + return Ok(()); + } + if pending.contains_key(&id) { + let _ = reply.send(Err(StdioTransportError::DuplicateRequestId)); + return Ok(()); + } + pending.insert( + id, + PendingRequest { + semantics, + generation, + deadline, + reply, + }, + ); + if process.write(payload, Some(permit), None).is_err() { + return Err(StdioTransportError::Closed); + } + } + ClientCommand::Notification { + payload, + permit, + deadline, + generation, + reply, + } => { + if generation.is_some_and(|generation| { + lifecycle.load(Ordering::Acquire) + != lifecycle_word(generation, LIFECYCLE_INITIALIZING) + }) { + let _ = reply.send(Err(StdioTransportError::Closed)); + return Ok(()); + } + if Instant::now() >= deadline { + let _ = reply.send(Err(StdioTransportError::Timeout)); + } else { + process.write(payload, Some(permit), Some(reply))?; + } + } + ClientCommand::Reset { .. } + | ClientCommand::Healthy { .. } + | ClientCommand::Shutdown { .. } => { + unreachable!("lifecycle commands are handled by the actor") + } + } + Ok(()) +} + +async fn handle_server_message( + message: Value, + process: &mut RunningProcess, + pending: &mut HashMap, + tool_list_changed: &broadcast::Sender<()>, + lifecycle: &AtomicU64, + limits: StdioTransportLimits, +) -> Result<(), StdioTransportError> { + let object = message + .as_object() + .ok_or(StdioTransportError::InvalidResponse)?; + if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(StdioTransportError::InvalidResponse); + } + if object.contains_key("method") { + if let Some(id) = object.get("id") { + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32601, "message": "Method not found" } + }); + let payload = encode_message(&response, limits.max_message_bytes)?; + process.write(payload, None, None)?; + } else if object.get("method").and_then(Value::as_str) + == Some("notifications/tools/list_changed") + { + let _ = tool_list_changed.send(()); + } + return Ok(()); + } + + let id = object + .get("id") + .and_then(Value::as_u64) + .ok_or(StdioTransportError::InvalidResponse)?; + let request = pending + .remove(&id) + .ok_or(StdioTransportError::UnexpectedResponseId)?; + if request.generation.is_some_and(|generation| { + lifecycle.load(Ordering::Acquire) != lifecycle_word(generation, LIFECYCLE_INITIALIZING) + }) { + let _ = request.reply.send(Err(StdioTransportError::Closed)); + return Ok(()); + } + let response = match (object.get("result"), object.get("error")) { + (Some(result), None) => Ok(result.clone()), + (None, Some(Value::Object(error))) => match ( + error.get("code").and_then(Value::as_i64), + error.get("message").and_then(Value::as_str), + ) { + (Some(code), Some(_)) => Err(StdioTransportError::JsonRpc { code }), + _ => Err(StdioTransportError::InvalidResponse), + }, + _ => Err(StdioTransportError::InvalidResponse), + }; + let _ = request.reply.send(response); + Ok(()) +} + +fn reject_command(command: ClientCommand, error: StdioTransportError) { + match command { + ClientCommand::Request { reply, .. } => { + let _ = reply.send(Err(error)); + } + ClientCommand::Notification { reply, .. } => { + let _ = reply.send(Err(error)); + } + ClientCommand::Reset { completed, .. } => { + let _ = completed.send(()); + } + ClientCommand::Healthy { .. } => {} + ClientCommand::Shutdown { completed } => { + if let Some(completed) = completed { + let _ = completed.send(()); + } + } + } +} + +fn command_reply_closed(command: &ClientCommand) -> bool { + match command { + ClientCommand::Request { reply, .. } => reply.is_closed(), + ClientCommand::Notification { reply, .. } => reply.is_closed(), + ClientCommand::Reset { completed, .. } => completed.is_closed(), + ClientCommand::Healthy { .. } => false, + ClientCommand::Shutdown { completed } => { + completed.as_ref().is_some_and(oneshot::Sender::is_closed) + } + } +} + +fn fail_pending(pending: &mut HashMap, failure: ProcessFailure) { + for (_, request) in pending.drain() { + let error = match request.semantics { + RequestSemantics::Replayable => StdioTransportError::ProcessExited { + code: failure.code, + stderr_truncated: failure.stderr_truncated, + }, + RequestSemantics::AmbiguousAfterWrite => StdioTransportError::AmbiguousToolCall, + }; + let _ = request.reply.send(Err(error)); + } +} + +#[derive(Default)] +struct RestartState { + consecutive_failures: u32, + retry_at: Option, +} + +impl RestartState { + fn record_failure(&mut self, limits: StdioTransportLimits) { + self.consecutive_failures = self.consecutive_failures.saturating_add(1); + let shift = self.consecutive_failures.saturating_sub(1).min(31); + let multiplier = 1_u32 << shift; + let delay = limits + .initial_restart_backoff + .saturating_mul(multiplier) + .min(limits.max_restart_backoff); + self.retry_at = Instant::now().checked_add(delay); + } + + fn record_success(&mut self) { + self.consecutive_failures = 0; + self.retry_at = None; + } +} + +struct RunningProcess { + child: Child, + writer: Option>, + writer_task: JoinHandle<()>, + writer_failed: mpsc::Receiver<()>, + stdout: BoundedMessageReader, + stderr: JoinHandle, + process_group: i32, +} + +impl RunningProcess { + async fn spawn( + template: &TrustedStdioTemplate, + limits: StdioTransportLimits, + ) -> Result { + for name in &template.secret_environment { + let value = template + .environment + .get(name) + .ok_or(StdioTemplateError::InvalidSecretOverlay)?; + if value.is_empty() + || value.len() > MAX_ENVIRONMENT_VALUE_BYTES + || value.as_bytes().contains(&0) + { + return Err(StdioTemplateError::InvalidSecretOverlay.into()); + } + } + verify_executable(template)?; + verify_cwd(template)?; + let mut command = Command::new(&template.executable); + command + .args(&template.arguments) + .env_clear() + .envs(&template.environment) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(cwd) = &template.cwd { + command.current_dir(cwd); + } + configure_process_group(&mut command); + let mut child = command.spawn().map_err(|_| StdioTransportError::Spawn)?; + let process_group = child.id().ok_or(StdioTransportError::Spawn)? as i32; + let stdin = child.stdin.take().ok_or(StdioTransportError::Spawn)?; + let stdout = child.stdout.take().ok_or(StdioTransportError::Spawn)?; + let stderr = child.stderr.take().ok_or(StdioTransportError::Spawn)?; + let stderr = tokio::spawn(drain_stderr(stderr, limits.max_stderr_bytes)); + let (writer, writes) = mpsc::channel(limits.command_queue_capacity); + let (writer_failure, writer_failed) = mpsc::channel(1); + let writer_task = tokio::spawn(write_frames(stdin, writes, writer_failure)); + Ok(Self { + child, + writer: Some(writer), + writer_task, + writer_failed, + stdout: BoundedMessageReader::new(stdout), + stderr, + process_group, + }) + } + + fn write( + &mut self, + payload: Vec, + permit: Option, + completion: Option>>, + ) -> Result<(), StdioTransportError> { + let frame = WriteFrame { + payload, + _permit: permit, + completion, + }; + match self + .writer + .as_ref() + .ok_or(StdioTransportError::Closed)? + .try_send(frame) + { + Ok(()) => Ok(()), + Err(error) => { + let mut frame = error.into_inner(); + if let Some(completion) = frame.completion.take() { + let _ = completion.send(Err(StdioTransportError::Closed)); + } + Err(StdioTransportError::TooManyInFlight) + } + } + } + + async fn stop(&mut self, limits: StdioTransportLimits) -> ProcessFailure { + signal_process_group(self.process_group, libc::SIGTERM); + let status = match timeout(limits.shutdown_grace, self.child.wait()).await { + Ok(Ok(status)) => Some(status), + _ => { + signal_process_group(self.process_group, libc::SIGKILL); + let _ = self.child.start_kill(); + self.child.wait().await.ok() + } + }; + let summary = self.finish_background(limits).await; + ProcessFailure { + code: status.as_ref().and_then(ExitStatus::code), + stderr_truncated: summary.truncated, + } + } + + async fn after_exit( + &mut self, + status: Option, + limits: StdioTransportLimits, + ) -> ProcessFailure { + signal_process_group(self.process_group, libc::SIGTERM); + let summary = self.finish_background(limits).await; + ProcessFailure { + code: status.as_ref().and_then(ExitStatus::code), + stderr_truncated: summary.truncated, + } + } + + async fn finish_background(&mut self, limits: StdioTransportLimits) -> StderrSummary { + self.writer.take(); + let summary = match timeout(limits.shutdown_grace, &mut self.stderr).await { + Ok(summary) => summary.unwrap_or_default(), + Err(_) => { + signal_process_group(self.process_group, libc::SIGKILL); + match timeout(limits.shutdown_grace, &mut self.stderr).await { + Ok(summary) => summary.unwrap_or_default(), + Err(_) => { + self.stderr.abort(); + StderrSummary { truncated: true } + } + } + } + }; + if timeout(limits.shutdown_grace, &mut self.writer_task) + .await + .is_err() + { + self.writer_task.abort(); + } + if process_group_exists(self.process_group) { + signal_process_group(self.process_group, libc::SIGKILL); + } + summary + } +} + +struct WriteFrame { + payload: Vec, + _permit: Option, + completion: Option>>, +} + +async fn write_frames( + mut stdin: W, + mut frames: mpsc::Receiver, + failure: mpsc::Sender<()>, +) where + W: AsyncWrite + Unpin, +{ + while let Some(frame) = frames.recv().await { + let mut frame = frame; + let result = async { + stdin.write_all(&frame.payload).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await + } + .await; + if let Some(completion) = frame.completion.take() { + let _ = completion.send(if result.is_ok() { + Ok(()) + } else { + Err(StdioTransportError::Closed) + }); + } + if result.is_err() { + let _ = failure.try_send(()); + break; + } + } + let _ = stdin.shutdown().await; +} + +#[derive(Clone, Copy)] +struct ProcessFailure { + code: Option, + stderr_truncated: bool, +} + +struct BoundedMessageReader { + reader: BufReader, +} + +impl BoundedMessageReader +where + R: AsyncRead + Unpin, +{ + fn new(reader: R) -> Self { + Self { + reader: BufReader::with_capacity(8 * 1024, reader), + } + } + + async fn read_message( + &mut self, + max_bytes: usize, + ) -> Result, StdioTransportError> { + let mut bytes = Vec::new(); + loop { + let available = self + .reader + .fill_buf() + .await + .map_err(|_| StdioTransportError::Closed)?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + return Err(StdioTransportError::InvalidResponse); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let consumed = newline.map_or(available.len(), |index| index + 1); + let content = newline.map_or(available, |index| &available[..index]); + if bytes.len().saturating_add(content.len()) > max_bytes { + return Err(StdioTransportError::ResponseTooLarge); + } + bytes.extend_from_slice(content); + self.reader.consume(consumed); + if newline.is_some() { + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + if bytes.is_empty() { + return Err(StdioTransportError::InvalidResponse); + } + return serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| StdioTransportError::InvalidResponse); + } + } + } +} + +#[derive(Default)] +struct StderrSummary { + truncated: bool, +} + +async fn drain_stderr(mut stderr: R, max_bytes: usize) -> StderrSummary +where + R: AsyncRead + Unpin, +{ + let mut retained = 0_usize; + let mut truncated = false; + let mut buffer = [0_u8; 8 * 1024]; + loop { + match stderr.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + if retained.saturating_add(read) > max_bytes { + truncated = true; + } + retained = retained.saturating_add(read).min(max_bytes); + } + } + } + StderrSummary { truncated } +} + +fn encode_message(value: &Value, max_bytes: usize) -> Result, StdioTransportError> { + let payload = serde_json::to_vec(value).map_err(|_| StdioTransportError::Encode)?; + if payload.len() > max_bytes { + return Err(StdioTransportError::RequestTooLarge); + } + Ok(payload) +} + +fn validate_template_name(name: &str) -> Result<(), StdioTemplateError> { + if name.is_empty() + || name.len() > MAX_TEMPLATE_NAME_BYTES + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(StdioTemplateError::InvalidName); + } + Ok(()) +} + +fn validate_arguments(arguments: &[String]) -> Result<(), StdioTemplateError> { + if arguments.len() > MAX_ARGUMENTS { + return Err(StdioTemplateError::TooManyArguments); + } + let mut total = 0_usize; + for argument in arguments { + if argument.len() > MAX_ARGUMENT_BYTES || argument.as_bytes().contains(&0) { + return Err(StdioTemplateError::ArgumentTooLarge); + } + total = total + .checked_add(argument.len()) + .ok_or(StdioTemplateError::TemplateTooLarge)?; + } + if total > MAX_TEMPLATE_BYTES { + return Err(StdioTemplateError::TemplateTooLarge); + } + Ok(()) +} + +fn validate_environment(environment: &BTreeMap) -> Result<(), StdioTemplateError> { + if environment.len() > MAX_ENVIRONMENT_ENTRIES { + return Err(StdioTemplateError::TooManyEnvironmentEntries); + } + let mut total = 0_usize; + for (name, value) in environment { + if !valid_environment_name(name) + || value.as_bytes().contains(&0) + || value.len() > MAX_ENVIRONMENT_VALUE_BYTES + { + return Err(StdioTemplateError::InvalidEnvironmentEntry); + } + total = total + .checked_add(name.len()) + .and_then(|total| total.checked_add(value.len())) + .ok_or(StdioTemplateError::TemplateTooLarge)?; + } + if total > MAX_TEMPLATE_BYTES { + return Err(StdioTemplateError::TemplateTooLarge); + } + Ok(()) +} + +fn validate_secret_environment( + secret_environment: &[String], + environment: &BTreeMap, +) -> Result<(), StdioTemplateError> { + if secret_environment.len() > MAX_ENVIRONMENT_ENTRIES { + return Err(StdioTemplateError::TooManyEnvironmentEntries); + } + let mut previous: Option<&str> = None; + let mut names = secret_environment + .iter() + .map(String::as_str) + .collect::>(); + names.sort_unstable(); + for name in names { + if !valid_environment_name(name) || environment.contains_key(name) || previous == Some(name) + { + return Err(StdioTemplateError::InvalidSecretFields); + } + previous = Some(name); + } + Ok(()) +} + +fn validate_secret_overlay( + approved: &[String], + secrets: &BTreeMap, +) -> Result<(), StdioTemplateError> { + if approved.len() != secrets.len() { + return Err(StdioTemplateError::InvalidSecretOverlay); + } + for name in approved { + let Some(value) = secrets.get(name) else { + return Err(StdioTemplateError::InvalidSecretOverlay); + }; + if value.is_empty() + || value.len() > MAX_ENVIRONMENT_VALUE_BYTES + || value.as_bytes().contains(&0) + { + return Err(StdioTemplateError::InvalidSecretOverlay); + } + } + if secrets.keys().any(|name| !approved.contains(name)) { + return Err(StdioTemplateError::InvalidSecretOverlay); + } + Ok(()) +} + +fn valid_environment_name(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn verify_executable(template: &TrustedStdioTemplate) -> Result<(), StdioTransportError> { + let canonical = + std::fs::canonicalize(&template.executable).map_err(|_| StdioTransportError::Spawn)?; + if canonical != template.executable { + return Err(StdioTransportError::Spawn); + } + let metadata = std::fs::metadata(&canonical).map_err(|_| StdioTransportError::Spawn)?; + if !metadata.is_file() { + return Err(StdioTransportError::Spawn); + } + validate_executable_permissions(&metadata).map_err(StdioTransportError::Template) +} + +fn verify_cwd(template: &TrustedStdioTemplate) -> Result<(), StdioTransportError> { + let Some(cwd) = &template.cwd else { + return Ok(()); + }; + let canonical = std::fs::canonicalize(cwd).map_err(|_| StdioTransportError::Spawn)?; + if &canonical != cwd + || !std::fs::metadata(&canonical) + .map_err(|_| StdioTransportError::Spawn)? + .is_dir() + { + return Err(StdioTransportError::Spawn); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_executable_permissions(metadata: &std::fs::Metadata) -> Result<(), StdioTemplateError> { + use std::os::unix::fs::PermissionsExt; + + if metadata.permissions().mode() & 0o111 == 0 { + return Err(StdioTemplateError::ExecutableNotExecutable); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_executable_permissions(_: &std::fs::Metadata) -> Result<(), StdioTemplateError> { + Ok(()) +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } +} + +#[cfg(not(unix))] +fn configure_process_group(_: &mut Command) {} + +#[cfg(unix)] +fn signal_process_group(process_group: i32, signal: i32) { + if process_group > 0 { + unsafe { + libc::kill(-process_group, signal); + } + } +} + +#[cfg(unix)] +fn process_group_exists(process_group: i32) -> bool { + process_group > 0 && unsafe { libc::kill(-process_group, 0) == 0 } +} + +#[cfg(not(unix))] +fn signal_process_group(_: i32, _: i32) {} + +#[cfg(not(unix))] +fn process_group_exists(_: i32) -> bool { + false +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use tempfile::TempDir; + use tokio::time::{Duration, Instant}; + + use super::*; + + fn script(directory: &TempDir, name: &str, contents: &str) -> PathBuf { + let path = directory.path().join(name); + std::fs::write(&path, contents).expect("script writes"); + let mut permissions = std::fs::metadata(&path) + .expect("script metadata exists") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&path, permissions).expect("script becomes executable"); + path + } + + fn template(path: PathBuf) -> TrustedStdioTemplate { + TrustedStdioTemplate::validate(StdioTemplate { + name: "fixture".to_owned(), + executable: path, + cwd: None, + arguments: Vec::new(), + environment: BTreeMap::new(), + secret_environment: Vec::new(), + }) + .expect("fixture template validates") + } + + async fn initialize_client(client: &StdioClient) { + client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await + .expect("initialization succeeds"); + } + + #[test] + fn templates_require_named_absolute_executables_and_reject_duplicates() { + let error = TrustedStdioTemplate::validate(StdioTemplate { + name: "bad name".to_owned(), + executable: PathBuf::from("relative-command"), + cwd: None, + arguments: Vec::new(), + environment: BTreeMap::new(), + secret_environment: Vec::new(), + }) + .expect_err("invalid name is rejected first"); + assert!(matches!(error, StdioTemplateError::InvalidName)); + + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script(&directory, "server", "#!/bin/sh\nexit 0\n"); + let config = StdioTemplate { + name: "fixture".to_owned(), + executable, + cwd: None, + arguments: Vec::new(), + environment: BTreeMap::new(), + secret_environment: Vec::new(), + }; + let error = StdioTemplateRegistry::new(vec![config.clone(), config]) + .expect_err("duplicate names are rejected"); + assert!(matches!(error, StdioTemplateError::DuplicateName)); + } + + #[test] + fn loads_a_bounded_strict_configuration_file() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script(&directory, "server", "#!/bin/sh\nexit 0\n"); + let config_path = directory.path().join("stdio.json"); + std::fs::write( + &config_path, + serde_json::to_vec(&json!({ + "templates": [{ + "name": "fixture", + "executable": executable, + "secretEnvironment": ["API_TOKEN"] + }] + })) + .expect("configuration encodes"), + ) + .expect("configuration writes"); + let registry = StdioTemplateRegistry::load(&config_path).expect("configuration loads"); + assert_eq!(registry.descriptors()[0].name, "fixture"); + + std::fs::write(&config_path, br#"{"templates":[],"unexpected":true}"#) + .expect("invalid configuration writes"); + assert!(matches!( + StdioTemplateRegistry::load(&config_path), + Err(StdioTemplateError::ConfigInvalid) + )); + + std::fs::write(&config_path, vec![b' '; MAX_CONFIG_FILE_BYTES as usize + 1]) + .expect("oversized configuration writes"); + assert!(matches!( + StdioTemplateRegistry::load(&config_path), + Err(StdioTemplateError::ConfigTooLarge) + )); + } + + #[tokio::test] + async fn initializes_lists_and_calls_with_clean_environment() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script( + &directory, + "server", + r#"#!/bin/sh +if [ -n "${HOME+x}" ]; then exit 9; fi +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fixture","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}' ;; + *'"method":"tools/list"'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}}' ;; + *'"method":"tools/call"'*) printf '%s\n' '{"jsonrpc":"2.0","id":77,"result":{"content":[{"type":"text","text":"ok"}],"isError":false}}' ;; + esac +done +"#, + ); + let client = StdioClient::connect(template(executable), StdioTransportLimits::default()) + .await + .expect("client connects"); + let mut list_changed = client.subscribe_tool_list_changed(); + let initialized = client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await + .expect("initialization succeeds"); + assert_eq!(initialized.protocol_version, DEFAULT_PROTOCOL_VERSION); + timeout(Duration::from_secs(1), list_changed.recv()) + .await + .expect("list changed notification arrives") + .expect("list changed channel remains open"); + let tools = client + .list_tools(None) + .await + .expect("tool listing succeeds"); + assert_eq!(tools.tools[0].name, "echo"); + let result = client + .call_tool("echo", json!({ "value": "hi" }), 77) + .await + .expect("tool call succeeds"); + assert!(!result.is_error); + client.shutdown().await; + } + + #[tokio::test] + async fn registry_exposes_only_secret_descriptors_and_requires_exact_overlay() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script( + &directory, + "secret-server", + r#"#!/bin/sh +if [ "$API_TOKEN" != "preserve surrounding spaces" ]; then exit 8; fi +read line +printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' +read line +read line +printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}' +"#, + ); + let registry = StdioTemplateRegistry::new(vec![StdioTemplate { + name: "secret-fixture".to_owned(), + executable, + cwd: None, + arguments: vec!["--trusted-argument".to_owned()], + environment: BTreeMap::new(), + secret_environment: vec!["API_TOKEN".to_owned()], + }]) + .expect("registry validates"); + assert_eq!( + registry.descriptors(), + vec![StdioTemplateDescriptor { + name: "secret-fixture".to_owned(), + secret_fields: vec!["API_TOKEN".to_owned()], + }] + ); + let error = match registry + .connect_with_secrets( + "secret-fixture", + &BTreeMap::new(), + StdioTransportLimits::default(), + ) + .await + { + Ok(client) => { + client.shutdown().await; + panic!("required secrets cannot be omitted"); + } + Err(error) => error, + }; + assert!(matches!( + error, + StdioTransportError::Template(StdioTemplateError::InvalidSecretOverlay) + )); + + let secrets = BTreeMap::from([( + "API_TOKEN".to_owned(), + "preserve surrounding spaces".to_owned(), + )]); + let client = registry + .connect_with_secrets("secret-fixture", &secrets, StdioTransportLimits::default()) + .await + .expect("approved secret overlay connects"); + initialize_client(&client).await; + assert!(client.list_tools(None).await.is_ok()); + client.shutdown().await; + } + + #[tokio::test] + async fn child_runs_in_the_exact_canonical_working_directory() { + let directory = TempDir::new().expect("temporary directory creates"); + let working_directory = directory.path().join("working"); + std::fs::create_dir(&working_directory).expect("working directory creates"); + let output = directory.path().join("cwd.txt"); + let executable = script( + &directory, + "cwd-server", + r#"#!/bin/sh +pwd > "$OUTPUT" +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' ;; + esac +done +"#, + ); + let registry = StdioTemplateRegistry::new(vec![StdioTemplate { + name: "cwd-fixture".to_owned(), + executable, + cwd: Some(working_directory.clone()), + arguments: Vec::new(), + environment: BTreeMap::from([( + "OUTPUT".to_owned(), + output.to_string_lossy().into_owned(), + )]), + secret_environment: Vec::new(), + }]) + .expect("working directory template validates"); + let client = registry + .connect_with_secrets( + "cwd-fixture", + &BTreeMap::new(), + StdioTransportLimits::default(), + ) + .await + .expect("client connects"); + initialize_client(&client).await; + assert_eq!( + std::fs::read_to_string(output) + .expect("working directory capture exists") + .trim(), + std::fs::canonicalize(working_directory) + .expect("working directory canonicalizes") + .to_string_lossy() + ); + client.shutdown().await; + } + + #[tokio::test] + async fn oversized_stdout_is_rejected_without_unbounded_buffering() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script( + &directory, + "server", + "#!/bin/sh\nread line\ni=0; while [ $i -lt 2048 ]; do printf x; i=$((i+1)); done; printf '\\n'\n", + ); + let limits = StdioTransportLimits { + max_message_bytes: 512, + ..StdioTransportLimits::default() + }; + let client = StdioClient::connect(template(executable), limits) + .await + .expect("client connects"); + let error = client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await + .expect_err("oversized response is rejected"); + assert!(matches!(error, StdioTransportError::ProcessExited { .. })); + client.shutdown().await; + } + + #[tokio::test] + async fn disconnected_tool_calls_are_never_retried() { + let directory = TempDir::new().expect("temporary directory creates"); + let counter = directory.path().join("counter"); + let executable = script( + &directory, + "server", + &format!( + "#!/bin/sh\nread line\nprintf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{{}}}}}}'\nread line\nread line\nprintf x >> '{}'\nexit 1\n", + counter.display() + ), + ); + let client = StdioClient::connect(template(executable), StdioTransportLimits::default()) + .await + .expect("client connects"); + initialize_client(&client).await; + let error = client + .call_tool("mutate", json!({}), 41) + .await + .expect_err("disconnected call has an ambiguous result"); + assert!(matches!(error, StdioTransportError::AmbiguousToolCall)); + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(std::fs::read(&counter).expect("counter exists"), b"x"); + client.shutdown().await; + } + + #[tokio::test] + async fn initialized_state_clears_when_an_idle_child_exits() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script( + &directory, + "server", + "#!/bin/sh\nread line\nprintf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}'\nread line\nexit 0\n", + ); + let client = StdioClient::connect(template(executable), StdioTransportLimits::default()) + .await + .expect("client connects"); + initialize_client(&client).await; + assert!(client.is_initialized()); + timeout(Duration::from_secs(1), async { + while client.is_initialized() { + tokio::task::yield_now().await; + } + }) + .await + .expect("idle child exit clears initialized state"); + client.shutdown().await; + } + + #[tokio::test] + async fn restart_attempts_observe_exponential_backoff() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script(&directory, "server", "#!/bin/sh\nexit 1\n"); + let limits = StdioTransportLimits { + initial_restart_backoff: Duration::from_millis(120), + max_restart_backoff: Duration::from_millis(120), + ..StdioTransportLimits::default() + }; + let client = StdioClient::connect(template(executable), limits) + .await + .expect("initial spawn succeeds"); + let _ = client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await; + let started = Instant::now(); + let _ = client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await; + assert!(started.elapsed() >= Duration::from_millis(100)); + client.shutdown().await; + } + + #[tokio::test] + async fn timed_out_requests_are_removed_and_force_reinitialization() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script( + &directory, + "timeout-server", + r#"#!/bin/sh +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' ;; + esac +done +"#, + ); + let limits = StdioTransportLimits { + request_timeout: Duration::from_millis(150), + shutdown_grace: Duration::from_millis(30), + initial_restart_backoff: Duration::from_millis(10), + max_restart_backoff: Duration::from_millis(20), + ..StdioTransportLimits::default() + }; + let client = StdioClient::connect(template(executable), limits) + .await + .expect("client connects"); + initialize_client(&client).await; + let started = Instant::now(); + let error = client + .list_tools(None) + .await + .expect_err("unanswered request times out"); + assert!(matches!(error, StdioTransportError::Timeout)); + assert!(started.elapsed() < Duration::from_secs(1)); + timeout(Duration::from_secs(1), async { + while lifecycle_state(client.lifecycle.load(Ordering::Acquire)) + != LIFECYCLE_UNINITIALIZED + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("timed out request teardown completes"); + assert!(matches!( + client.list_tools(None).await, + Err(StdioTransportError::NotInitialized) + )); + client.shutdown().await; + } + + #[tokio::test] + async fn stdout_is_drained_while_large_concurrent_requests_are_written() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script( + &directory, + "duplex-server", + r#"#!/bin/sh +read line +printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' +read line +i=0 +while [ $i -lt 1400 ]; do + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}' + i=$((i+1)) +done +i=2 +while IFS= read -r line; do + printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$i" + i=$((i+1)) +done +"#, + ); + let client = std::sync::Arc::new( + StdioClient::connect(template(executable), StdioTransportLimits::default()) + .await + .expect("client connects"), + ); + initialize_client(&client).await; + let mut calls = tokio::task::JoinSet::new(); + for _ in 0..64 { + let client = client.clone(); + calls.spawn(async move { + client + .list_tools(Some(&"x".repeat(4 * 1024))) + .await + .expect("concurrent list succeeds") + }); + } + timeout(Duration::from_secs(5), async { + while let Some(result) = calls.join_next().await { + result.expect("list task completes"); + } + }) + .await + .expect("full-duplex traffic does not deadlock"); + std::sync::Arc::try_unwrap(client) + .ok() + .expect("all task client references are dropped") + .shutdown() + .await; + } + + #[tokio::test] + async fn shutdown_kills_the_process_group_even_when_descendants_ignore_term() { + let directory = TempDir::new().expect("temporary directory creates"); + let descendant_pid = directory.path().join("descendant.pid"); + let executable = script( + &directory, + "process-group-server", + r#"#!/bin/sh +trap '' TERM +(trap '' TERM; while :; do /bin/sleep 1; done) & +printf '%s' "$!" > "$PID_FILE" +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' ;; + esac +done +"#, + ); + let mut trusted = template(executable); + trusted.environment.insert( + "PID_FILE".to_owned(), + descendant_pid.to_string_lossy().into_owned(), + ); + let limits = StdioTransportLimits { + shutdown_grace: Duration::from_millis(40), + initial_restart_backoff: Duration::from_millis(10), + max_restart_backoff: Duration::from_millis(20), + ..StdioTransportLimits::default() + }; + let client = StdioClient::connect(trusted, limits) + .await + .expect("client connects"); + initialize_client(&client).await; + let pid = std::fs::read_to_string(&descendant_pid) + .expect("descendant PID is captured") + .parse::() + .expect("descendant PID is numeric"); + let started = Instant::now(); + client.shutdown().await; + assert!(started.elapsed() < Duration::from_secs(1)); + for _ in 0..200 { + if unsafe { libc::kill(pid, 0) } == -1 { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("descendant process remained alive after process-group shutdown"); + } + + #[tokio::test] + async fn failed_initialize_response_restarts_before_retry() { + let directory = TempDir::new().expect("temporary directory creates"); + let marker = directory.path().join("started"); + let executable = script( + &directory, + "initialize-retry-server", + r#"#!/bin/sh +read line +if [ -f "$MARKER" ]; then + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' +else + printf x > "$MARKER" + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}' +fi +while IFS= read -r line; do :; done +"#, + ); + let mut trusted = template(executable); + trusted + .environment + .insert("MARKER".to_owned(), marker.to_string_lossy().into_owned()); + let client = StdioClient::connect(trusted, StdioTransportLimits::default()) + .await + .expect("client connects"); + let error = client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await + .expect_err("mismatched protocol version is rejected"); + assert!(matches!( + error, + StdioTransportError::ProtocolVersionMismatch + )); + initialize_client(&client).await; + client.shutdown().await; + } + + #[tokio::test] + async fn initialize_waits_for_initialized_notification_flush() { + let (writer, mut reader) = tokio::io::duplex(1); + let (frames, queued) = mpsc::channel(1); + let (failure, _) = mpsc::channel(1); + let writer_task = tokio::spawn(write_frames(writer, queued, failure)); + let (completed, mut completion) = oneshot::channel(); + frames + .send(WriteFrame { + payload: b"initialized".to_vec(), + _permit: None, + completion: Some(completed), + }) + .await + .expect("notification frame queues"); + + assert!( + timeout(Duration::from_millis(30), &mut completion) + .await + .is_err(), + "notification must not report success while its write is blocked" + ); + let mut wire = vec![0_u8; b"initialized\n".len()]; + reader + .read_exact(&mut wire) + .await + .expect("notification is drained from the wire"); + completion + .await + .expect("writer reports notification completion") + .expect("write and flush succeed"); + assert_eq!(wire, b"initialized\n"); + drop(frames); + writer_task.await.expect("writer task exits cleanly"); + } + + #[tokio::test] + async fn shutdown_interrupts_restart_backoff() { + let directory = TempDir::new().expect("temporary directory creates"); + let executable = script(&directory, "crash-server", "#!/bin/sh\nexit 1\n"); + let limits = StdioTransportLimits { + request_timeout: Duration::from_secs(5), + initial_restart_backoff: Duration::from_secs(2), + max_restart_backoff: Duration::from_secs(2), + ..StdioTransportLimits::default() + }; + let client = std::sync::Arc::new( + StdioClient::connect(template(executable), limits) + .await + .expect("initial spawn succeeds"), + ); + let _ = client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await; + let retry_client = client.clone(); + let retry = tokio::spawn(async move { + retry_client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + let (completed, completion) = oneshot::channel(); + let started = Instant::now(); + client + .commands + .send(ClientCommand::Shutdown { + completed: Some(completed), + }) + .await + .expect("shutdown queues"); + timeout(Duration::from_millis(500), completion) + .await + .expect("shutdown interrupts backoff") + .expect("actor acknowledges shutdown"); + assert!(started.elapsed() < Duration::from_millis(500)); + assert!(retry.await.expect("retry task completes").is_err()); + } + + #[tokio::test] + async fn canceling_initialize_resets_before_retry() { + let directory = TempDir::new().expect("temporary directory creates"); + let marker = directory.path().join("first-start"); + let executable = script( + &directory, + "cancel-initialize-server", + r#"#!/bin/sh +read line +if [ -f "$MARKER" ]; then + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' +else + printf x > "$MARKER" +fi +while IFS= read -r line; do :; done +"#, + ); + let mut trusted = template(executable); + trusted + .environment + .insert("MARKER".to_owned(), marker.to_string_lossy().into_owned()); + let limits = StdioTransportLimits { + request_timeout: Duration::from_secs(2), + shutdown_grace: Duration::from_millis(40), + initial_restart_backoff: Duration::from_millis(10), + max_restart_backoff: Duration::from_millis(20), + ..StdioTransportLimits::default() + }; + let client = std::sync::Arc::new( + StdioClient::connect(trusted, limits) + .await + .expect("client connects"), + ); + let initializing_client = client.clone(); + let initializing = tokio::spawn(async move { + initializing_client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await + }); + timeout(Duration::from_secs(1), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("first initialization reaches child"); + initializing.abort(); + let _ = initializing.await; + timeout(Duration::from_secs(1), async { + while lifecycle_state(client.lifecycle.load(Ordering::Acquire)) + != LIFECYCLE_UNINITIALIZED + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("canceled initialization cleanup completes"); + initialize_client(&client).await; + std::sync::Arc::try_unwrap(client) + .ok() + .expect("initialization task released its client") + .shutdown() + .await; + } + + #[tokio::test] + async fn stale_lifecycle_commands_cannot_mutate_a_new_generation() { + let directory = TempDir::new().expect("temporary directory creates"); + let marker = directory.path().join("first-generation"); + let executable = script( + &directory, + "generation-server", + r#"#!/bin/sh +read line +if [ -f "$MARKER" ]; then + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' + read line + read line + printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"tools":[]}}' +else + printf x > "$MARKER" + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{}}}' +fi +while IFS= read -r line; do :; done +"#, + ); + let mut trusted = template(executable); + trusted + .environment + .insert("MARKER".to_owned(), marker.to_string_lossy().into_owned()); + let client = StdioClient::connect(trusted, StdioTransportLimits::default()) + .await + .expect("client connects"); + initialize_client(&client).await; + let first_generation = lifecycle_generation(client.lifecycle.load(Ordering::Acquire)); + client.reset_transport(first_generation).await; + initialize_client(&client).await; + + let (completed, completion) = oneshot::channel(); + client + .commands + .send(ClientCommand::Reset { + generation: first_generation, + completed, + }) + .await + .expect("stale reset queues"); + completion.await.expect("stale reset is acknowledged"); + assert_eq!( + lifecycle_state(client.lifecycle.load(Ordering::Acquire)), + LIFECYCLE_INITIALIZED + ); + assert!(matches!( + client + .notify( + "notifications/initialized", + json!({}), + Some(first_generation), + ) + .await, + Err(StdioTransportError::Closed) + )); + assert_eq!( + lifecycle_state(client.lifecycle.load(Ordering::Acquire)), + LIFECYCLE_INITIALIZED + ); + assert!(client.list_tools(None).await.is_ok()); + client.shutdown().await; + } + + #[tokio::test] + async fn mismatched_initialization_does_not_reset_exponential_backoff() { + let directory = TempDir::new().expect("temporary directory creates"); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + let executable = script( + &directory, + "mismatch-server", + r#"#!/bin/sh +read line +if [ ! -f "$FIRST" ]; then + printf x > "$FIRST"; id=1 +elif [ ! -f "$SECOND" ]; then + printf x > "$SECOND"; id=2 +else + id=3 +fi +printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' "$id" +while IFS= read -r line; do :; done +"#, + ); + let mut trusted = template(executable); + trusted.environment.extend([ + ("FIRST".to_owned(), first.to_string_lossy().into_owned()), + ("SECOND".to_owned(), second.to_string_lossy().into_owned()), + ]); + let limits = StdioTransportLimits { + request_timeout: Duration::from_secs(1), + shutdown_grace: Duration::from_millis(20), + initial_restart_backoff: Duration::from_millis(60), + max_restart_backoff: Duration::from_millis(120), + ..StdioTransportLimits::default() + }; + let client = StdioClient::connect(trusted, limits) + .await + .expect("client connects"); + for (attempt, minimum_delay) in [ + (0, Duration::ZERO), + (1, Duration::from_millis(45)), + (2, Duration::from_millis(100)), + ] { + let started = Instant::now(); + let error = client + .initialize( + DEFAULT_PROTOCOL_VERSION, + json!({ "name": "executor", "version": "1" }), + json!({}), + ) + .await + .expect_err("mismatched version remains rejected"); + assert!(matches!( + error, + StdioTransportError::ProtocolVersionMismatch + )); + assert!( + started.elapsed() >= minimum_delay, + "attempt {attempt} did not observe its accumulated backoff" + ); + } + client.shutdown().await; + } + + #[test] + fn limits_and_merged_secret_environment_are_strictly_bounded() { + let limits = StdioTransportLimits { + initial_restart_backoff: Duration::from_secs(31), + max_restart_backoff: Duration::from_secs(31), + ..StdioTransportLimits::default() + }; + assert!(matches!( + limits.validate(), + Err(StdioTransportError::InvalidLimits) + )); + + let approved = (0..128).map(|index| format!("SECRET_{index}")); + let secrets = approved + .clone() + .map(|name| (name, "value".to_owned())) + .collect::>(); + let base = BTreeMap::from([("STATIC".to_owned(), "value".to_owned())]); + assert!(validate_environment(&base).is_ok()); + let mut merged = base; + merged.extend(secrets); + assert!(matches!( + validate_environment(&merged), + Err(StdioTemplateError::TooManyEnvironmentEntries) + )); + } + + #[test] + fn packed_lifecycle_transitions_reject_stale_generation_races() { + let lifecycle = AtomicU64::new(lifecycle_word(1, LIFECYCLE_INITIALIZING)); + assert!(matches!( + acquire_lifecycle_generation(&lifecycle), + Err(StdioTransportError::AlreadyInitialized) + )); + reset_lifecycle_generation(&lifecycle, 0); + assert_eq!( + lifecycle.load(Ordering::Acquire), + lifecycle_word(1, LIFECYCLE_INITIALIZING) + ); + reset_lifecycle_generation(&lifecycle, 1); + let second = acquire_lifecycle_generation(&lifecycle).expect("next generation acquires"); + assert_eq!(second, 2); + + assert!( + lifecycle + .compare_exchange( + lifecycle_word(1, LIFECYCLE_INITIALIZING), + lifecycle_word(1, LIFECYCLE_INITIALIZED), + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err(), + "stale finalization cannot initialize the next generation" + ); + reset_lifecycle_generation(&lifecycle, 1); + assert_eq!( + lifecycle.load(Ordering::Acquire), + lifecycle_word(2, LIFECYCLE_INITIALIZING), + "stale reset fallback cannot clear the next generation" + ); + } +} diff --git a/src/outbound.rs b/src/outbound.rs index b5eac094f..9357c148d 100644 --- a/src/outbound.rs +++ b/src/outbound.rs @@ -170,6 +170,60 @@ pub struct OutboundResponse { pub final_url: Url, } +pub struct OutboundStreamResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub final_url: Url, + response: reqwest::Response, + bytes_read: usize, + max_response_bytes: usize, + timeout: StreamTimeout, + declared_response_too_large: bool, +} + +enum StreamTimeout { + Absolute(tokio::time::Instant), + Idle(Duration), +} + +impl OutboundStreamResponse { + pub fn declared_response_too_large(&self) -> bool { + self.declared_response_too_large + } + + pub async fn next_chunk(&mut self) -> Result>, OutboundError> { + let remaining = match self.timeout { + StreamTimeout::Absolute(deadline) => deadline + .checked_duration_since(tokio::time::Instant::now()) + .ok_or(OutboundError::Timeout)?, + StreamTimeout::Idle(timeout) => timeout, + }; + let Some(chunk) = tokio::time::timeout(remaining, self.response.chunk()) + .await + .map_err(|_| OutboundError::Timeout)? + .map_err(map_reqwest_error)? + else { + return Ok(None); + }; + match self.timeout { + StreamTimeout::Absolute(_) => { + self.bytes_read = self + .bytes_read + .checked_add(chunk.len()) + .ok_or(OutboundError::ResponseBodyTooLarge)?; + if self.bytes_read > self.max_response_bytes { + return Err(OutboundError::ResponseBodyTooLarge); + } + } + StreamTimeout::Idle(_) if chunk.len() > self.max_response_bytes => { + return Err(OutboundError::ResponseBodyTooLarge); + } + StreamTimeout::Idle(_) => {} + } + Ok(Some(chunk.to_vec())) + } +} + #[derive(Clone, Debug)] pub struct HardenedHttpClient { policy: OutboundPolicy, @@ -196,6 +250,50 @@ impl HardenedHttpClient { .map_err(|_| OutboundError::Timeout)? } + pub async fn execute_streaming( + &self, + request: OutboundRequest, + ) -> Result { + validate_request(&request, &self.policy)?; + tokio::time::timeout( + self.policy.request_timeout, + self.execute_streaming_once(request, false, false), + ) + .await + .map_err(|_| OutboundError::Timeout)? + } + + pub async fn execute_streaming_headers_first( + &self, + request: OutboundRequest, + ) -> Result { + validate_request(&request, &self.policy)?; + tokio::time::timeout( + self.policy.request_timeout, + self.execute_streaming_once(request, false, true), + ) + .await + .map_err(|_| OutboundError::Timeout)? + } + + pub async fn execute_long_lived_streaming( + &self, + request: OutboundRequest, + idle_timeout: Duration, + ) -> Result { + validate_request(&request, &self.policy)?; + tokio::time::timeout( + self.policy.request_timeout, + self.execute_streaming_once(request, true, true), + ) + .await + .map_err(|_| OutboundError::Timeout)? + .map(|mut response| { + response.timeout = StreamTimeout::Idle(idle_timeout); + response + }) + } + pub async fn fetch_spec( &self, url: Url, @@ -257,8 +355,27 @@ impl HardenedHttpClient { async fn execute_once( &self, - mut request: OutboundRequest, + request: OutboundRequest, ) -> Result { + let mut response = self.execute_streaming_once(request, false, false).await?; + let mut body = Vec::new(); + while let Some(chunk) = response.next_chunk().await? { + body.extend_from_slice(&chunk); + } + Ok(OutboundResponse { + status: response.status, + headers: response.headers, + body, + final_url: response.final_url, + }) + } + + async fn execute_streaming_once( + &self, + mut request: OutboundRequest, + long_lived: bool, + allow_declared_oversize: bool, + ) -> Result { validate_request(&request, &self.policy)?; let resolved = resolve_target(&request.url, &self.policy).await?; if !request.headers.contains_key(ACCEPT_ENCODING) { @@ -274,8 +391,10 @@ impl HardenedHttpClient { .redirect(Policy::none()) .referer(false) .connect_timeout(self.policy.connect_timeout) - .timeout(self.policy.request_timeout) .pool_max_idle_per_host(0); + if !long_lived { + builder = builder.timeout(self.policy.request_timeout); + } if let Some(hostname) = resolved.hostname.as_deref() { builder = builder.resolve_to_addrs(hostname, &resolved.addresses); } @@ -289,26 +408,23 @@ impl HardenedHttpClient { .send() .await .map_err(map_reqwest_error)?; - validate_response_headers(response.headers(), &self.policy)?; - let status = response.status(); - let headers = response.headers().clone(); - let mut body = Vec::new(); - let mut response = response; - while let Some(chunk) = response.chunk().await.map_err(map_reqwest_error)? { - let next_length = body - .len() - .checked_add(chunk.len()) - .ok_or(OutboundError::ResponseBodyTooLarge)?; - if next_length > self.policy.max_response_bytes { - return Err(OutboundError::ResponseBodyTooLarge); - } - body.extend_from_slice(&chunk); - } - Ok(OutboundResponse { - status, - headers, - body, + let declared_response_too_large = + match validate_response_headers(response.headers(), &self.policy) { + Err(OutboundError::ResponseBodyTooLarge) if allow_declared_oversize => true, + Err(error) => return Err(error), + Ok(()) => false, + }; + Ok(OutboundStreamResponse { + status: response.status(), + headers: response.headers().clone(), final_url: request.url, + response, + bytes_read: 0, + max_response_bytes: self.policy.max_response_bytes, + timeout: StreamTimeout::Absolute( + tokio::time::Instant::now() + self.policy.request_timeout, + ), + declared_response_too_large, }) } } diff --git a/src/protocols/mcp.rs b/src/protocols/mcp.rs new file mode 100644 index 000000000..8dcd36670 --- /dev/null +++ b/src/protocols/mcp.rs @@ -0,0 +1,3518 @@ +use std::{collections::BTreeMap, future::Future, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use thiserror::Error; +use tokio::sync::broadcast::error::{RecvError, TryRecvError}; +use url::Url; + +use super::{ + ConfiguredCredential, CredentialMetadata, ProtocolError, ProtocolErrorCategory, + ProtocolExecutionResponse, ProtocolResponseError, protocol_catalog_error, +}; +use crate::{ + catalog::{ + AuditContext, CatalogStore, CatalogSyncResult, CreateSource, CredentialPayload, + InitialCatalogSnapshot, McpToolBindingV1, SourceHealth, SourceKind, SourceRecord, + StoredCredential, + }, + mcp::{ + discovery::{ + DiscoveredMcpTool, DiscoveryBasis, DiscoveryError, DiscoveryPlan, ListChangedCoalescer, + ToolPage, ToolPageFetcher, bindings_for_source_kind, discover, + }, + manager::{McpConnectionManager, WatcherRevisionLease}, + upstream::{ + http::{ + DEFAULT_PROTOCOL_VERSION as HTTP_PROTOCOL_VERSION, StreamableHttpConfig, + StreamableHttpError, StreamableHttpTransport, + }, + stdio::{ + DEFAULT_PROTOCOL_VERSION as STDIO_PROTOCOL_VERSION, InitializeResult, + StdioLifecycleMonitor, StdioTemplateError, StdioTemplateRegistry, + StdioTransportError, StdioTransportLimits, + }, + }, + }, +}; + +const MCP_CREDENTIAL_SCHEMA_VERSION: u32 = 1; +const MAX_ENDPOINT_BYTES: usize = 8 * 1024; +const MAX_CREDENTIAL_BYTES: usize = 64 * 1024; +const MAX_SECRET_VALUES: usize = 128; +const MAX_SECRET_NAME_BYTES: usize = 256; +const MAX_SECRET_VALUE_BYTES: usize = 16 * 1024; +const MAX_DISCOVERY_GENERATIONS: usize = 8; +const DISCOVERY_DEADLINE: Duration = Duration::from_secs(120); +const DISCOVERY_QUIET_PERIOD: Duration = Duration::from_millis(25); +const STDIO_WATCHER_HEARTBEAT: Duration = Duration::from_secs(1); + +#[derive(Clone, Deserialize, Serialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum McpHttpCredential { + Bearer { token: String }, + Basic { username: String, password: String }, + ApiKeyHeader { name: String, value: String }, + OAuthAccessToken { access_token: String }, +} + +impl McpHttpCredential { + fn credential_type(&self) -> &'static str { + match self { + Self::Bearer { .. } => "bearer", + Self::Basic { .. } => "basic", + Self::ApiKeyHeader { .. } => "api_key_header", + Self::OAuthAccessToken { .. } => "oauth_access_token", + } + } + + fn headers(&self) -> Result { + let mut headers = HeaderMap::new(); + match self { + Self::Bearer { token } + | Self::OAuthAccessToken { + access_token: token, + } => { + let value = HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|_| invalid_credentials("The MCP bearer credential is invalid."))?; + headers.insert(AUTHORIZATION, value); + } + Self::Basic { username, password } => { + let encoded = STANDARD.encode(format!("{username}:{password}")); + let value = HeaderValue::from_str(&format!("Basic {encoded}")) + .map_err(|_| invalid_credentials("The MCP basic credential is invalid."))?; + headers.insert(AUTHORIZATION, value); + } + Self::ApiKeyHeader { name, value } => { + let name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| invalid_credentials("The MCP API key header name is invalid."))?; + if protected_mcp_header(&name) { + return Err(invalid_credentials( + "The MCP API key cannot use a protected HTTP header.", + )); + } + let value = HeaderValue::from_str(value) + .map_err(|_| invalid_credentials("The MCP API key header value is invalid."))?; + headers.insert(name, value); + } + } + Ok(headers) + } + + fn validate(&self) -> Result<(), ProtocolError> { + match self { + Self::Bearer { token } + | Self::OAuthAccessToken { + access_token: token, + } => { + validate_secret(token)?; + } + Self::Basic { username, password } => { + if username.is_empty() || username.contains(':') || username.len() > 4 * 1024 { + return Err(invalid_credentials( + "The MCP basic credential username is invalid.", + )); + } + validate_secret(password)?; + } + Self::ApiKeyHeader { name, value } => { + if name.is_empty() || name.len() > 256 { + return Err(invalid_credentials( + "The MCP API key header name is invalid.", + )); + } + validate_secret(value)?; + } + } + let headers = self.headers()?; + let mut config = StreamableHttpConfig::new("https://example.invalid/mcp"); + config.headers = headers; + StreamableHttpTransport::new(config).map_err(http_configuration_error)?; + Ok(()) + } +} + +fn protected_mcp_header(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "host" + | "content-type" + | "accept" + | "content-length" + | "connection" + | "transfer-encoding" + | "mcp-session-id" + | "mcp-protocol-version" + | "origin" + | "referer" + | "authorization" + | "proxy-authorization" + | "proxy-connection" + ) || name.as_str().starts_with("proxy-") +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateMcpHttpSource { + pub display_name: String, + pub preferred_slug: Option, + pub description: Option, + pub endpoint: String, + #[serde(default)] + pub allow_private_network: bool, + pub credential: Option, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateMcpStdioSource { + pub display_name: String, + pub preferred_slug: Option, + pub description: Option, + pub template_name: String, + #[serde(default)] + pub secret_values: BTreeMap, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplaceMcpHttpCredential { + pub credential: Option, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplaceMcpStdioCredential { + #[serde(default)] + pub secret_values: BTreeMap, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct McpHttpSourceConfigurationV1 { + endpoint: String, + allow_private_network: bool, + negotiated_protocol_version: String, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct McpStdioSourceConfigurationV1 { + template_name: String, + negotiated_protocol_version: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoredMcpHttpCredentialV1 { + endpoint: String, + credential: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoredMcpStdioCredentialV1 { + template_name: String, + secret_values: BTreeMap, +} + +#[derive(Clone)] +pub struct McpAdapter { + connections: Arc, +} + +impl Default for McpAdapter { + fn default() -> Self { + Self::new(StdioTemplateRegistry::default()) + } +} + +impl McpAdapter { + pub fn new(stdio_templates: StdioTemplateRegistry) -> Self { + Self { + connections: Arc::new(McpConnectionManager::new(stdio_templates)), + } + } + + pub fn stdio_templates(&self) -> &StdioTemplateRegistry { + self.connections.stdio_templates() + } + + pub(crate) fn with_connection_manager(connections: Arc) -> Self { + Self { connections } + } + + pub async fn create_http_source( + &self, + catalog: &CatalogStore, + input: CreateMcpHttpSource, + audit: AuditContext<'_>, + ) -> Result { + validate_http_credential(input.credential.as_ref())?; + let endpoint = validate_endpoint(&input.endpoint)?; + let stored = StoredMcpHttpCredentialV1 { + endpoint: endpoint.to_string(), + credential: input.credential, + }; + let _operation = self.connections.begin_operation().map_err(shutting_down)?; + let plan = discover_http(&stored, input.allow_private_network, 0, Some(0)).await?; + let configuration = McpHttpSourceConfigurationV1 { + endpoint: display_endpoint(&endpoint), + allow_private_network: input.allow_private_network, + negotiated_protocol_version: plan.basis.protocol_version.clone(), + }; + let preferred_slug = input + .preferred_slug + .unwrap_or_else(|| input.display_name.clone()); + let (source, _) = catalog + .create_source_with_catalog( + CreateSource { + kind: SourceKind::McpHttp, + preferred_slug, + display_name: input.display_name, + description: input.description, + configuration: encode_configuration(&configuration)?, + }, + &stored.payload()?, + plan.initial_catalog_snapshot(), + plan.bindings, + audit, + ) + .await + .map_err(protocol_catalog_error)?; + self.install_source_watcher(catalog, &source).await; + Ok(source) + } + + pub async fn create_stdio_source( + &self, + catalog: &CatalogStore, + input: CreateMcpStdioSource, + audit: AuditContext<'_>, + ) -> Result { + self.connections + .stdio_templates() + .template(&input.template_name) + .map_err(input_template_error)?; + validate_secret_values(&input.secret_values)?; + let discover_now = stdio_secrets_complete( + self.connections.stdio_templates(), + &input.template_name, + &input.secret_values, + )?; + let stored = StoredMcpStdioCredentialV1 { + template_name: input.template_name.clone(), + secret_values: input.secret_values, + }; + let (snapshot, bindings, negotiated_protocol_version) = if discover_now { + let _operation = self.connections.begin_operation().map_err(shutting_down)?; + let plan = + discover_stdio(self.connections.stdio_templates(), &stored, 0, Some(0)).await?; + let negotiated_protocol_version = plan.basis.protocol_version.clone(); + ( + plan.initial_catalog_snapshot(), + plan.bindings, + negotiated_protocol_version, + ) + } else { + ( + InitialCatalogSnapshot { + artifacts: Vec::new(), + tools: Vec::new(), + }, + Vec::new(), + HTTP_PROTOCOL_VERSION.to_owned(), + ) + }; + let configuration = McpStdioSourceConfigurationV1 { + template_name: input.template_name, + negotiated_protocol_version: discover_now.then_some(negotiated_protocol_version), + }; + let preferred_slug = input + .preferred_slug + .unwrap_or_else(|| input.display_name.clone()); + let create = CreateSource { + kind: SourceKind::McpStdio, + preferred_slug, + display_name: input.display_name, + description: input.description, + configuration: encode_configuration(&configuration)?, + }; + let credential = stored.payload()?; + let (source, _) = if discover_now { + catalog + .create_source_with_catalog(create, &credential, snapshot, bindings, audit) + .await + } else { + catalog + .create_source_with_catalog_health( + create, + &credential, + snapshot, + bindings, + SourceHealth::Unknown, + audit, + ) + .await + } + .map_err(protocol_catalog_error)?; + if discover_now { + self.install_source_watcher(catalog, &source).await; + } + Ok(source) + } + + pub async fn refresh_source( + &self, + catalog: &CatalogStore, + source: SourceRecord, + audit: AuditContext<'_>, + ) -> Result { + let _operation = self.connections.begin_operation().map_err(shutting_down)?; + let source_id = source.id.clone(); + let source_revision = source.revision; + let result = match self.refresh_source_core(catalog, source, audit).await { + Ok(result) => result, + Err(error) => { + let _ = catalog + .mark_source_error(&source_id, "mcp_refresh_failed", source_revision, audit) + .await; + return Err(error); + } + }; + let current = catalog + .source(&source_id) + .await + .map_err(protocol_catalog_error)?; + self.install_source_watcher(catalog, ¤t).await; + Ok(result) + } + + async fn refresh_source_core( + &self, + catalog: &CatalogStore, + source: SourceRecord, + audit: AuditContext<'_>, + ) -> Result { + let stored = required_stored_credential(catalog, &source.id).await?; + let _operation = self.connections.begin_operation().map_err(shutting_down)?; + let plan = match source.kind { + SourceKind::McpHttp => { + let configuration = McpHttpSourceConfigurationV1::decode(&source.configuration)?; + let credential = StoredMcpHttpCredentialV1::decode(&stored)?; + discover_http( + &credential, + configuration.allow_private_network, + source.revision, + Some(stored.revision), + ) + .await? + } + SourceKind::McpStdio => { + let configuration = McpStdioSourceConfigurationV1::decode(&source.configuration)?; + let credential = StoredMcpStdioCredentialV1::decode(&stored)?; + if configuration.template_name != credential.template_name { + return Err(corrupt_configuration()); + } + self.connections + .stdio_templates() + .template(&credential.template_name) + .map_err(stored_template_error)?; + discover_stdio( + self.connections.stdio_templates(), + &credential, + source.revision, + Some(stored.revision), + ) + .await? + } + SourceKind::Openapi | SourceKind::Graphql => { + return Err(ProtocolError::corrupt( + "source_protocol_mismatch", + "The stored source does not match the MCP protocol.", + )); + } + }; + let result = catalog + .sync_catalog_with_bindings(&source.id, plan.catalog_snapshot(), plan.bindings, audit) + .await + .map_err(protocol_catalog_error)?; + Ok(result) + } + + pub async fn credential_metadata( + &self, + catalog: &CatalogStore, + source: &SourceRecord, + ) -> Result { + let stored = required_stored_credential(catalog, &source.id).await?; + match source.kind { + SourceKind::McpHttp => { + let credential = StoredMcpHttpCredentialV1::decode(&stored)?; + Ok(http_credential_metadata(stored.revision, &credential)) + } + SourceKind::McpStdio => { + let credential = StoredMcpStdioCredentialV1::decode(&stored)?; + Ok(stdio_credential_metadata(stored.revision, &credential)) + } + SourceKind::Openapi | SourceKind::Graphql => Err(ProtocolError::corrupt( + "source_protocol_mismatch", + "The stored source does not match the MCP protocol.", + )), + } + } + + pub async fn replace_http_credentials( + &self, + catalog: &CatalogStore, + source_id: &str, + expected_revision: i64, + update: ReplaceMcpHttpCredential, + audit: AuditContext<'_>, + ) -> Result { + let _operation = self.connections.begin_operation().map_err(shutting_down)?; + validate_http_credential(update.credential.as_ref())?; + let stored = required_stored_credential(catalog, source_id).await?; + require_revision(expected_revision, stored.revision)?; + let mut credential = StoredMcpHttpCredentialV1::decode(&stored)?; + let Some(candidate) = update.credential else { + return self + .clear_http_credentials(catalog, source_id, stored, credential, audit) + .await; + }; + credential.credential = Some(candidate); + let source = catalog + .source(source_id) + .await + .map_err(protocol_catalog_error)?; + let configuration = McpHttpSourceConfigurationV1::decode(&source.configuration)?; + let plan = discover_http( + &credential, + configuration.allow_private_network, + source.revision, + Some(stored.revision), + ) + .await?; + let (stored, _synced, source) = catalog + .replace_credential_and_sync_catalog( + source_id, + &credential.payload()?, + plan.catalog_snapshot(), + plan.bindings, + audit, + ) + .await + .map_err(protocol_catalog_error)?; + self.connections + .stop_watcher_and_wait_at_revision(source_id, source.revision) + .await; + let credential = StoredMcpHttpCredentialV1::decode(&stored)?; + let metadata = http_credential_metadata(stored.revision, &credential); + self.install_source_watcher(catalog, &source).await; + Ok(metadata) + } + + async fn clear_http_credentials( + &self, + catalog: &CatalogStore, + source_id: &str, + stored: StoredCredential, + mut credential: StoredMcpHttpCredentialV1, + audit: AuditContext<'_>, + ) -> Result { + let source = catalog + .source(source_id) + .await + .map_err(protocol_catalog_error)?; + let configuration = McpHttpSourceConfigurationV1::decode(&source.configuration)?; + credential.credential = None; + let anonymous_discovery = discover_http( + &credential, + configuration.allow_private_network, + source.revision, + Some(stored.revision), + ) + .await; + self.connections + .stop_watcher_and_wait_at_revision(source_id, source.revision) + .await; + let committed = match anonymous_discovery { + Ok(plan) => catalog + .replace_credential_and_sync_catalog( + source_id, + &credential.payload()?, + plan.catalog_snapshot(), + plan.bindings, + audit, + ) + .await + .map(|(stored, _, source)| (stored, source, true)), + Err(error) => { + tracing::info!( + source_id, + code = error.code, + "MCP HTTP source does not support anonymous discovery after credential clear" + ); + catalog + .replace_credential_and_mark_unknown( + source_id, + &credential.payload()?, + source.revision, + stored.revision, + audit, + ) + .await + .map(|(stored, source)| (stored, source, false)) + } + }; + let (stored, source, install_watcher) = match committed { + Ok(committed) => committed, + Err(error) => { + self.install_source_watcher(catalog, &source).await; + return Err(protocol_catalog_error(error)); + } + }; + let credential = StoredMcpHttpCredentialV1::decode(&stored)?; + let metadata = http_credential_metadata(stored.revision, &credential); + if install_watcher { + self.install_source_watcher(catalog, &source).await; + } + Ok(metadata) + } + + pub async fn replace_stdio_credentials( + &self, + catalog: &CatalogStore, + source_id: &str, + expected_revision: i64, + update: ReplaceMcpStdioCredential, + audit: AuditContext<'_>, + ) -> Result { + let _operation = self.connections.begin_operation().map_err(shutting_down)?; + validate_secret_values(&update.secret_values)?; + let stored = required_stored_credential(catalog, source_id).await?; + require_revision(expected_revision, stored.revision)?; + let mut credential = StoredMcpStdioCredentialV1::decode(&stored)?; + if !stdio_secrets_complete( + self.connections.stdio_templates(), + &credential.template_name, + &update.secret_values, + )? { + return Err(invalid_credentials( + "The MCP stdio source requires all template secret values.", + )); + } + credential.secret_values = update.secret_values; + let source = catalog + .source(source_id) + .await + .map_err(protocol_catalog_error)?; + let plan = discover_stdio( + self.connections.stdio_templates(), + &credential, + source.revision, + Some(stored.revision), + ) + .await?; + let (stored, _synced, source) = catalog + .replace_credential_and_sync_catalog( + source_id, + &credential.payload()?, + plan.catalog_snapshot(), + plan.bindings, + audit, + ) + .await + .map_err(protocol_catalog_error)?; + self.connections + .stop_watcher_and_wait_at_revision(source_id, source.revision) + .await; + let credential = StoredMcpStdioCredentialV1::decode(&stored)?; + let metadata = stdio_credential_metadata(stored.revision, &credential); + self.install_source_watcher(catalog, &source).await; + Ok(metadata) + } + + pub async fn clear_credentials( + &self, + catalog: &CatalogStore, + source: &SourceRecord, + expected_revision: i64, + audit: AuditContext<'_>, + ) -> Result { + match source.kind { + SourceKind::McpHttp => { + self.replace_http_credentials( + catalog, + &source.id, + expected_revision, + ReplaceMcpHttpCredential { credential: None }, + audit, + ) + .await + } + SourceKind::McpStdio => { + let _operation = self.connections.begin_operation().map_err(shutting_down)?; + let stored = required_stored_credential(catalog, &source.id).await?; + require_revision(expected_revision, stored.revision)?; + let mut credential = StoredMcpStdioCredentialV1::decode(&stored)?; + credential.secret_values.clear(); + if stdio_secrets_complete( + self.connections.stdio_templates(), + &credential.template_name, + &credential.secret_values, + )? { + return self + .replace_stdio_credentials( + catalog, + &source.id, + expected_revision, + ReplaceMcpStdioCredential { + secret_values: BTreeMap::new(), + }, + audit, + ) + .await; + } + self.connections + .stop_watcher_and_wait_at_revision(&source.id, source.revision) + .await; + let cleared = catalog + .replace_credential_and_mark_unknown( + &source.id, + &credential.payload()?, + source.revision, + stored.revision, + audit, + ) + .await; + let (stored, committed_source) = match cleared { + Ok(cleared) => cleared, + Err(error) => { + self.install_source_watcher(catalog, source).await; + return Err(protocol_catalog_error(error)); + } + }; + self.connections + .stop_watcher_and_wait_at_revision( + &committed_source.id, + committed_source.revision, + ) + .await; + let credential = StoredMcpStdioCredentialV1::decode(&stored)?; + Ok(stdio_credential_metadata(stored.revision, &credential)) + } + SourceKind::Openapi | SourceKind::Graphql => Err(ProtocolError::corrupt( + "source_protocol_mismatch", + "The stored source does not match the MCP protocol.", + )), + } + } + + async fn install_source_watcher(&self, catalog: &CatalogStore, source: &SourceRecord) { + match source_supports_list_changed(catalog, &source.id).await { + Ok(true) => {} + Ok(false) => { + self.connections + .stop_watcher_at_revision(&source.id, source.revision) + .await; + return; + } + Err(error) => { + tracing::warn!( + source_id = source.id, + code = error.code, + "MCP watcher capability load failed" + ); + return; + } + } + let stored = match required_stored_credential(catalog, &source.id).await { + Ok(stored) => stored, + Err(error) => { + tracing::warn!( + source_id = source.id, + code = error.code, + "MCP watcher credential load failed" + ); + return; + } + }; + let source_id = source.id.clone(); + let source_revision = source.revision; + let credential_revision = stored.revision; + let connections = self.connections.clone(); + let catalog = catalog.clone(); + let installed = match source.kind { + SourceKind::McpHttp => { + let configuration = + match McpHttpSourceConfigurationV1::decode(&source.configuration) { + Ok(configuration) => configuration, + Err(error) => { + tracing::warn!( + source_id = source.id, + code = error.code, + "MCP HTTP watcher configuration is invalid" + ); + return; + } + }; + let credential = match StoredMcpHttpCredentialV1::decode(&stored) { + Ok(credential) => credential, + Err(error) => { + tracing::warn!( + source_id = source.id, + code = error.code, + "MCP HTTP watcher credentials are invalid" + ); + return; + } + }; + self.connections + .replace_watcher( + source_id.clone(), + source.revision, + move |canceled, revision_lease| { + run_http_watcher( + HttpWatcherContext { + connections, + catalog, + source_id, + credential, + allow_private_network: configuration.allow_private_network, + source_revision, + credential_revision, + revision_lease, + }, + canceled, + ) + }, + ) + .await + } + SourceKind::McpStdio => { + let configuration = + match McpStdioSourceConfigurationV1::decode(&source.configuration) { + Ok(configuration) => configuration, + Err(error) => { + tracing::warn!( + source_id = source.id, + code = error.code, + "MCP stdio watcher configuration is invalid" + ); + return; + } + }; + let credential = match StoredMcpStdioCredentialV1::decode(&stored) { + Ok(credential) if credential.template_name == configuration.template_name => { + credential + } + Ok(_) => { + tracing::warn!( + source_id = source.id, + "MCP stdio watcher template state is inconsistent" + ); + return; + } + Err(error) => { + tracing::warn!( + source_id = source.id, + code = error.code, + "MCP stdio watcher credentials are invalid" + ); + return; + } + }; + self.connections + .replace_watcher( + source_id.clone(), + source.revision, + move |canceled, revision_lease| { + run_stdio_watcher( + StdioWatcherContext { + connections, + catalog, + source_id, + credential, + source_revision, + credential_revision, + revision_lease, + }, + canceled, + ) + }, + ) + .await + } + SourceKind::Openapi | SourceKind::Graphql => return, + }; + if installed.is_err() { + tracing::debug!( + source_id = source.id, + "MCP watcher was not installed during shutdown" + ); + } + } + + async fn ensure_source_watcher(&self, catalog: &CatalogStore, source: &SourceRecord) { + if self.connections.has_watcher(&source.id) { + if matches!( + source_supports_list_changed(catalog, &source.id).await, + Ok(false) + ) { + self.connections + .stop_watcher_at_revision(&source.id, source.revision) + .await; + } + return; + } + self.install_source_watcher(catalog, source).await; + } + + pub async fn restore_watchers(&self, catalog: &CatalogStore) -> Result<(), ProtocolError> { + let sources = catalog + .list_sources() + .await + .map_err(protocol_catalog_error)?; + for source in sources { + if matches!(source.kind, SourceKind::McpHttp | SourceKind::McpStdio) { + self.ensure_source_watcher(catalog, &source).await; + } + } + Ok(()) + } + + pub async fn restore_source_watcher(&self, catalog: &CatalogStore, source: &SourceRecord) { + self.ensure_source_watcher(catalog, source).await; + } + + pub async fn retire_source_watcher(&self, source_id: &str) { + self.connections.retire_source(source_id).await; + } + + pub async fn unretire_source_watcher(&self, source_id: &str) { + self.connections.unretire_source(source_id).await; + } + + pub(super) fn prepare_invocation( + &self, + source_kind: SourceKind, + binding: &McpToolBindingV1, + source_configuration: &Map, + stored: Option<&StoredCredential>, + arguments: &Value, + ) -> Result { + if binding.version != 1 || binding.tool_name.is_empty() { + return Err(ProtocolError::corrupt( + "unsupported_binding_schema", + "The stored MCP tool binding schema is not supported.", + )); + } + if !arguments.is_object() { + return Err(ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_tool_arguments", + "The tool arguments are invalid.", + )); + } + let stored = stored.ok_or_else(|| { + ProtocolError::corrupt( + "source_credentials_missing", + "The source credential state is missing.", + ) + })?; + match source_kind { + SourceKind::McpHttp => { + let configuration = McpHttpSourceConfigurationV1::decode(source_configuration)?; + let credential = StoredMcpHttpCredentialV1::decode_for_invocation(stored)?; + let config = + http_transport_config(&credential, configuration.allow_private_network)?; + StreamableHttpTransport::new(config.clone()).map_err(http_configuration_error)?; + Ok(PreparedMcpInvocation::Http { + config, + tool_name: binding.tool_name.clone(), + arguments: arguments.clone(), + }) + } + SourceKind::McpStdio => { + let configuration = McpStdioSourceConfigurationV1::decode(source_configuration)?; + let credential = StoredMcpStdioCredentialV1::decode_for_invocation(stored)?; + if configuration.template_name != credential.template_name { + return Err(corrupt_configuration()); + } + if !stdio_secrets_complete( + self.connections.stdio_templates(), + &credential.template_name, + &credential.secret_values, + )? { + return Err(ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "missing_source_credentials", + "The MCP stdio source requires credentials before its tools can run.", + )); + } + self.connections + .stdio_templates() + .template(&credential.template_name) + .map_err(stored_template_error)?; + Ok(PreparedMcpInvocation::Stdio { + template_name: credential.template_name, + secret_values: credential.secret_values, + tool_name: binding.tool_name.clone(), + arguments: arguments.clone(), + }) + } + SourceKind::Openapi | SourceKind::Graphql => Err(ProtocolError::corrupt( + "source_binding_mismatch", + "The stored tool binding does not match its source protocol.", + )), + } + } + + pub(super) async fn execute_invocation( + &self, + prepared: PreparedMcpInvocation, + ) -> Result { + let _operation = self + .connections + .begin_operation() + .map_err(|_| McpInvocationError::ShuttingDown)?; + match prepared { + PreparedMcpInvocation::Http { + config, + tool_name, + arguments, + } => execute_http(config, &tool_name, arguments).await, + PreparedMcpInvocation::Stdio { + template_name, + secret_values, + tool_name, + arguments, + } => { + execute_stdio( + self.connections.stdio_templates(), + &template_name, + &secret_values, + &tool_name, + arguments, + ) + .await + } + } + } +} + +struct HttpWatcherContext { + connections: Arc, + catalog: CatalogStore, + source_id: String, + credential: StoredMcpHttpCredentialV1, + allow_private_network: bool, + source_revision: i64, + credential_revision: i64, + revision_lease: WatcherRevisionLease, +} + +async fn run_http_watcher( + context: HttpWatcherContext, + mut canceled: tokio::sync::oneshot::Receiver<()>, +) { + let HttpWatcherContext { + connections, + catalog, + source_id, + credential, + allow_private_network, + mut source_revision, + credential_revision, + revision_lease, + } = context; + let mut retry_delay = Duration::from_secs(1); + let revisions = Arc::new(std::sync::atomic::AtomicI64::new(source_revision)); + loop { + source_revision = revisions.load(std::sync::atomic::Ordering::Acquire); + let operation = match connections.begin_operation() { + Ok(operation) => operation, + Err(_) => return, + }; + let config = match http_transport_config(&credential, allow_private_network) { + Ok(config) => config, + Err(error) => { + tracing::warn!( + source_id, + code = error.code, + "MCP HTTP watcher configuration failed" + ); + return; + } + }; + let transport = match StreamableHttpTransport::new(config) { + Ok(transport) => transport, + Err(_) => { + tracing::warn!(source_id, "MCP HTTP watcher transport failed"); + return; + } + }; + let mut changed = transport.subscribe_tool_list_changed(); + let initialized = match transport.initialize().await { + Ok(initialized) => initialized, + Err(_) => { + tracing::warn!(source_id, "MCP HTTP watcher initialization failed"); + drop(operation); + if watcher_retry_or_cancel(&mut canceled, retry_delay).await { + return; + } + retry_delay = next_watcher_backoff(retry_delay); + continue; + } + }; + let basis = + match http_discovery_basis(initialized, source_revision, Some(credential_revision)) { + Ok(basis) => basis, + Err(error) => { + tracing::warn!( + source_id, + code = error.code, + "MCP HTTP watcher initialization metadata is invalid" + ); + let _ = transport.terminate().await; + return; + } + }; + let listener_transport = transport.clone(); + let mut listener = + tokio::spawn(async move { listener_transport.listen_notifications().await }); + let reconciled = { + let reconciliation = reconcile_watcher_session(async { + let plan = discover_http_session(&transport, &mut changed, basis.clone()).await?; + commit_watcher_discovery(&catalog, &source_id, plan, &revision_lease, &revisions) + .await + }); + tokio::pin!(reconciliation); + tokio::select! { + _ = &mut canceled => { + abort_and_join(&mut listener).await; + let _ = transport.terminate().await; + return; + } + result = &mut reconciliation => result, + } + }; + let mut listener_finished = false; + let mut reconnect = false; + match reconciled { + Ok(reconciled) => { + if let Some(listener_result) = + finished_http_listener_after_reconciliation(&reconciled, &mut listener).await + { + listener_finished = true; + if let Ok(Err(error)) = listener_result { + if permanent_http_notification_error(&error) { + let _ = transport.terminate().await; + return; + } + tracing::debug!( + source_id, + "MCP HTTP notification listener disconnected after reconciliation" + ); + } + reconnect = true; + } + let (synced, supports_list_changed) = reconciled.into_inner(); + source_revision = synced.source_revision; + revisions.store(source_revision, std::sync::atomic::Ordering::Release); + retry_delay = Duration::from_secs(1); + if !supports_list_changed { + connections + .stop_watcher_at_revision(&source_id, source_revision) + .await; + if !listener_finished { + abort_and_join(&mut listener).await; + } + let _ = transport.terminate().await; + return; + } + } + Err(error) => { + tracing::warn!( + source_id, + code = error.code, + "MCP HTTP watcher startup reconciliation failed" + ); + abort_and_join(&mut listener).await; + let _ = transport.terminate().await; + drop(operation); + if watcher_retry_or_cancel(&mut canceled, retry_delay).await { + return; + } + retry_delay = next_watcher_backoff(retry_delay); + continue; + } + } + let coalescer = ListChangedCoalescer::default(); + while !reconnect { + tokio::select! { + _ = &mut canceled => { + abort_and_join(&mut listener).await; + let _ = transport.terminate().await; + return; + } + result = &mut listener => { + listener_finished = true; + if let Ok(Err(error)) = result { + if permanent_http_notification_error(&error) { + let _ = transport.terminate().await; + return; + } + tracing::debug!(source_id, "MCP HTTP notification listener disconnected"); + } + reconnect = true; + } + signal = changed.recv() => { + match signal { + Ok(()) | Err(RecvError::Lagged(_)) => { + retry_delay = Duration::from_secs(1); + coalescer.notify_changed(); + drain_change_signals(&mut changed, &coalescer); + let refresh = coalescer.refresh_pending(|| { + refresh_http_watcher_session( + catalog.clone(), + source_id.clone(), + transport.clone(), + basis.clone(), + connections.clone(), + revision_lease.clone(), + revisions.clone(), + ) + }); + tokio::pin!(refresh); + match wait_for_watcher_refresh( + &mut canceled, + &mut listener, + &mut refresh, + ) + .await + { + WatcherRefreshWait::Canceled => { + abort_and_join(&mut listener).await; + let _ = transport.terminate().await; + return; + } + WatcherRefreshWait::Disconnected(result) => { + listener_finished = true; + if let Ok(Err(error)) = result { + if permanent_http_notification_error(&error) { + let _ = transport.terminate().await; + return; + } + tracing::debug!(source_id, "MCP HTTP notification listener disconnected"); + } + reconnect = true; + } + WatcherRefreshWait::Completed(result) => { + if let Err(error) = result { + tracing::warn!(source_id, code = error.code, "MCP HTTP notification refresh failed"); + let mut refresh_delay = Duration::from_secs(1); + 'http_refresh_retries: while coalescer.has_pending() { + match wait_for_watcher_refresh( + &mut canceled, + &mut listener, + tokio::time::sleep(refresh_delay), + ) + .await + { + WatcherRefreshWait::Canceled => { + abort_and_join(&mut listener).await; + let _ = transport.terminate().await; + return; + } + WatcherRefreshWait::Disconnected(result) => { + listener_finished = true; + if let Ok(Err(error)) = result + && permanent_http_notification_error(&error) + { + let _ = transport.terminate().await; + return; + } + tracing::debug!(source_id, "MCP HTTP notification listener disconnected during refresh retry"); + reconnect = true; + break 'http_refresh_retries; + } + WatcherRefreshWait::Completed(()) => {} + } + if reconnect { + break; + } + let retried = coalescer.refresh_pending(|| { + refresh_http_watcher_session( + catalog.clone(), + source_id.clone(), + transport.clone(), + basis.clone(), + connections.clone(), + revision_lease.clone(), + revisions.clone(), + ) + }); + tokio::pin!(retried); + match wait_for_watcher_refresh( + &mut canceled, + &mut listener, + &mut retried, + ) + .await + { + WatcherRefreshWait::Canceled => { + abort_and_join(&mut listener).await; + let _ = transport.terminate().await; + return; + } + WatcherRefreshWait::Disconnected(result) => { + listener_finished = true; + if let Ok(Err(error)) = result + && permanent_http_notification_error(&error) + { + let _ = transport.terminate().await; + return; + } + tracing::debug!(source_id, "MCP HTTP notification listener disconnected during refresh retry"); + reconnect = true; + break 'http_refresh_retries; + } + WatcherRefreshWait::Completed(result) => { + if result.is_ok() { + break 'http_refresh_retries; + } + } + } + refresh_delay = next_watcher_backoff(refresh_delay); + } + } + } + } + } + Err(RecvError::Closed) => reconnect = true, + } + } + } + } + if !listener_finished { + abort_and_join(&mut listener).await; + } + let _ = transport.terminate().await; + drop(operation); + if watcher_retry_or_cancel(&mut canceled, retry_delay).await { + return; + } + retry_delay = next_watcher_backoff(retry_delay); + } +} + +async fn abort_and_join(task: &mut tokio::task::JoinHandle) { + task.abort(); + let _ = task.await; +} + +struct StdioWatcherContext { + connections: Arc, + catalog: CatalogStore, + source_id: String, + credential: StoredMcpStdioCredentialV1, + source_revision: i64, + credential_revision: i64, + revision_lease: WatcherRevisionLease, +} + +async fn run_stdio_watcher( + context: StdioWatcherContext, + mut canceled: tokio::sync::oneshot::Receiver<()>, +) { + let StdioWatcherContext { + connections, + catalog, + source_id, + credential, + mut source_revision, + credential_revision, + revision_lease, + } = context; + let mut retry_delay = Duration::from_secs(1); + let revisions = Arc::new(std::sync::atomic::AtomicI64::new(source_revision)); + loop { + source_revision = revisions.load(std::sync::atomic::Ordering::Acquire); + let operation = match connections.begin_operation() { + Ok(operation) => operation, + Err(_) => return, + }; + let client = match connections + .stdio_templates() + .connect_with_secrets( + &credential.template_name, + &credential.secret_values, + StdioTransportLimits::default(), + ) + .await + { + Ok(client) => client, + Err(_) => { + tracing::warn!(source_id, "MCP stdio watcher transport failed"); + drop(operation); + if watcher_retry_or_cancel(&mut canceled, retry_delay).await { + return; + } + retry_delay = next_watcher_backoff(retry_delay); + continue; + } + }; + let mut changed = client.subscribe_tool_list_changed(); + let initialized = match client + .initialize( + STDIO_PROTOCOL_VERSION, + client_info(), + Value::Object(Map::new()), + ) + .await + { + Ok(initialized) => initialized, + Err(_) => { + tracing::warn!(source_id, "MCP stdio watcher initialization failed"); + client.shutdown().await; + drop(operation); + if watcher_retry_or_cancel(&mut canceled, retry_delay).await { + return; + } + retry_delay = next_watcher_backoff(retry_delay); + continue; + } + }; + let basis = + match stdio_discovery_basis(initialized, source_revision, Some(credential_revision)) { + Ok(basis) => basis, + Err(error) => { + tracing::warn!( + source_id, + code = error.code, + "MCP stdio watcher initialization metadata is invalid" + ); + client.shutdown().await; + return; + } + }; + match watcher_reconcile_or_cancel(&mut canceled, || { + reconcile_watcher_session(async { + let plan = discover_stdio_session(&client, &mut changed, basis.clone()).await?; + commit_watcher_discovery(&catalog, &source_id, plan, &revision_lease, &revisions) + .await + }) + }) + .await + { + None => { + client.shutdown().await; + return; + } + Some(Ok(reconciled)) => { + let (synced, supports_list_changed) = reconciled.into_inner(); + source_revision = synced.source_revision; + revisions.store(source_revision, std::sync::atomic::Ordering::Release); + retry_delay = Duration::from_secs(1); + if !supports_list_changed { + connections + .stop_watcher_at_revision(&source_id, source_revision) + .await; + client.shutdown().await; + return; + } + } + Some(Err(error)) => { + tracing::warn!( + source_id, + code = error.code, + "MCP stdio watcher startup reconciliation failed" + ); + client.shutdown().await; + drop(operation); + if watcher_retry_or_cancel(&mut canceled, retry_delay).await { + return; + } + retry_delay = next_watcher_backoff(retry_delay); + continue; + } + } + let lifecycle = client.lifecycle_monitor(); + let client = Arc::new(tokio::sync::Mutex::new(Some(client))); + let coalescer = ListChangedCoalescer::default(); + let mut heartbeat = tokio::time::interval(STDIO_WATCHER_HEARTBEAT); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat.tick().await; + 'stdio_notifications: loop { + tokio::select! { + _ = &mut canceled => { + shutdown_shared_stdio(client).await; + return; + } + _ = heartbeat.tick() => { + if !lifecycle.is_initialized() { + tracing::debug!(source_id, "MCP stdio watcher disconnected"); + break; + } + } + signal = changed.recv() => { + match signal { + Ok(()) | Err(RecvError::Lagged(_)) => { + retry_delay = Duration::from_secs(1); + coalescer.notify_changed(); + drain_change_signals(&mut changed, &coalescer); + let refresh_result = { + let refresh = coalescer.refresh_pending(|| { + refresh_stdio_watcher_session( + catalog.clone(), + source_id.clone(), + client.clone(), + basis.clone(), + connections.clone(), + revision_lease.clone(), + revisions.clone(), + ) + }); + tokio::pin!(refresh); + wait_for_watcher_refresh( + &mut canceled, + wait_for_stdio_disconnect(&lifecycle), + &mut refresh, + ) + .await + }; + let refresh_result = match refresh_result { + WatcherRefreshWait::Canceled => { + shutdown_shared_stdio(client.clone()).await; + return; + } + WatcherRefreshWait::Disconnected(()) => { + tracing::debug!(source_id, "MCP stdio watcher disconnected during refresh"); + break 'stdio_notifications; + } + WatcherRefreshWait::Completed(result) => result, + }; + if let Err(error) = refresh_result { + tracing::warn!(source_id, code = error.code, "MCP stdio notification refresh failed"); + let mut refresh_delay = Duration::from_secs(1); + 'stdio_refresh_retries: while coalescer.has_pending() { + match wait_for_watcher_refresh( + &mut canceled, + wait_for_stdio_disconnect(&lifecycle), + tokio::time::sleep(refresh_delay), + ) + .await + { + WatcherRefreshWait::Canceled => { + shutdown_shared_stdio(client.clone()).await; + return; + } + WatcherRefreshWait::Disconnected(()) => { + tracing::debug!(source_id, "MCP stdio watcher disconnected during refresh retry"); + break 'stdio_notifications; + } + WatcherRefreshWait::Completed(()) => {} + } + let retried = coalescer.refresh_pending(|| { + refresh_stdio_watcher_session( + catalog.clone(), + source_id.clone(), + client.clone(), + basis.clone(), + connections.clone(), + revision_lease.clone(), + revisions.clone(), + ) + }); + tokio::pin!(retried); + match wait_for_watcher_refresh( + &mut canceled, + wait_for_stdio_disconnect(&lifecycle), + &mut retried, + ) + .await + { + WatcherRefreshWait::Canceled => { + shutdown_shared_stdio(client.clone()).await; + return; + } + WatcherRefreshWait::Disconnected(()) => { + tracing::debug!(source_id, "MCP stdio watcher disconnected during refresh retry"); + break 'stdio_notifications; + } + WatcherRefreshWait::Completed(Ok(_)) => { + break 'stdio_refresh_retries; + } + WatcherRefreshWait::Completed(Err(_)) => {} + } + refresh_delay = next_watcher_backoff(refresh_delay); + } + } + } + Err(RecvError::Closed) => break, + } + } + } + } + shutdown_shared_stdio(client).await; + drop(operation); + if watcher_retry_or_cancel(&mut canceled, retry_delay).await { + return; + } + retry_delay = next_watcher_backoff(retry_delay); + } +} + +fn drain_change_signals( + receiver: &mut tokio::sync::broadcast::Receiver<()>, + coalescer: &ListChangedCoalescer, +) { + loop { + match receiver.try_recv() { + Ok(()) | Err(TryRecvError::Lagged(_)) => { + coalescer.notify_changed(); + } + Err(TryRecvError::Empty | TryRecvError::Closed) => return, + } + } +} + +async fn refresh_http_watcher_session( + catalog: CatalogStore, + source_id: String, + transport: StreamableHttpTransport, + mut basis: DiscoveryBasis, + connections: Arc, + revision_lease: WatcherRevisionLease, + revisions: Arc, +) -> Result<(), ProtocolError> { + basis.expected_source_revision = revisions.load(std::sync::atomic::Ordering::Acquire); + let mut changed = transport.subscribe_tool_list_changed(); + let plan = discover_http_session(&transport, &mut changed, basis).await?; + let (_, supports_list_changed) = + commit_watcher_discovery(&catalog, &source_id, plan, &revision_lease, &revisions).await?; + if !supports_list_changed { + connections + .stop_watcher_at_revision( + &source_id, + revisions.load(std::sync::atomic::Ordering::Acquire), + ) + .await; + } + Ok(()) +} + +async fn refresh_stdio_watcher_session( + catalog: CatalogStore, + source_id: String, + client: Arc>>, + mut basis: DiscoveryBasis, + connections: Arc, + revision_lease: WatcherRevisionLease, + revisions: Arc, +) -> Result<(), ProtocolError> { + basis.expected_source_revision = revisions.load(std::sync::atomic::Ordering::Acquire); + let client = client.lock().await; + let client = client.as_ref().ok_or_else(stale_watcher_reconciliation)?; + let mut changed = client.subscribe_tool_list_changed(); + let plan = discover_stdio_session(client, &mut changed, basis).await?; + let (_, supports_list_changed) = + commit_watcher_discovery(&catalog, &source_id, plan, &revision_lease, &revisions).await?; + if !supports_list_changed { + connections + .stop_watcher_at_revision( + &source_id, + revisions.load(std::sync::atomic::Ordering::Acquire), + ) + .await; + } + Ok(()) +} + +async fn shutdown_shared_stdio( + client: Arc>>, +) { + if let Some(client) = client.lock().await.take() { + client.shutdown().await; + } +} + +async fn wait_for_stdio_disconnect(lifecycle: &StdioLifecycleMonitor) { + loop { + if !lifecycle.is_initialized() { + return; + } + tokio::time::sleep(STDIO_WATCHER_HEARTBEAT).await; + } +} + +enum WatcherRefreshWait { + Canceled, + Disconnected(Disconnect), + Completed(Output), +} + +async fn wait_for_watcher_refresh( + canceled: &mut tokio::sync::oneshot::Receiver<()>, + disconnect: Disconnect, + refresh: Refresh, +) -> WatcherRefreshWait +where + Disconnect: Future, + Refresh: Future, +{ + tokio::select! { + _ = canceled => WatcherRefreshWait::Canceled, + disconnected = disconnect => WatcherRefreshWait::Disconnected(disconnected), + result = refresh => WatcherRefreshWait::Completed(result), + } +} + +async fn commit_watcher_discovery( + catalog: &CatalogStore, + source_id: &str, + plan: DiscoveryPlan, + revision_lease: &WatcherRevisionLease, + revisions: &std::sync::atomic::AtomicI64, +) -> Result<(CatalogSyncResult, bool), ProtocolError> { + let expected_revision = plan.basis.expected_source_revision; + let supports_list_changed = plan.basis.tools_list_changed; + let revision_guard = revision_lease + .lock_revision(expected_revision) + .await + .ok_or_else(stale_watcher_reconciliation)?; + let result = catalog + .sync_catalog_with_bindings( + source_id, + plan.catalog_snapshot(), + plan.bindings, + AuditContext::system(None), + ) + .await + .map_err(protocol_catalog_error)?; + if !revision_guard.advance(result.source_revision) { + return Err(stale_watcher_reconciliation()); + } + revisions.store(result.source_revision, std::sync::atomic::Ordering::Release); + Ok((result, supports_list_changed)) +} + +struct ReconciledWatcherSession(Output); + +impl ReconciledWatcherSession { + fn into_inner(self) -> Output { + self.0 + } +} + +async fn finished_http_listener_after_reconciliation( + _reconciled: &ReconciledWatcherSession, + listener: &mut tokio::task::JoinHandle>, +) -> Option, tokio::task::JoinError>> { + if listener.is_finished() { + Some(listener.await) + } else { + None + } +} + +async fn reconcile_watcher_session( + reconciliation: Reconciliation, +) -> Result, Error> +where + Reconciliation: Future>, +{ + reconciliation.await.map(ReconciledWatcherSession) +} + +async fn watcher_reconcile_or_cancel( + canceled: &mut tokio::sync::oneshot::Receiver<()>, + refresh: Refresh, +) -> Option +where + Refresh: FnOnce() -> RefreshFuture, + RefreshFuture: Future, +{ + tokio::select! { + _ = canceled => None, + result = refresh() => Some(result), + } +} + +async fn watcher_retry_or_cancel( + canceled: &mut tokio::sync::oneshot::Receiver<()>, + delay: Duration, +) -> bool { + tokio::select! { + _ = canceled => true, + _ = tokio::time::sleep(delay) => false, + } +} + +fn next_watcher_backoff(current: Duration) -> Duration { + current.saturating_mul(2).min(Duration::from_secs(60)) +} + +fn permanent_http_notification_error(error: &StreamableHttpError) -> bool { + matches!( + error, + StreamableHttpError::HttpStatus(reqwest::StatusCode::METHOD_NOT_ALLOWED) + | StreamableHttpError::InvalidSessionId + | StreamableHttpError::NotInitialized + ) +} + +#[must_use = "a prepared MCP invocation must be executed or explicitly discarded"] +pub(super) enum PreparedMcpInvocation { + Http { + config: StreamableHttpConfig, + tool_name: String, + arguments: Value, + }, + Stdio { + template_name: String, + secret_values: BTreeMap, + tool_name: String, + arguments: Value, + }, +} + +#[derive(Debug, Error)] +pub enum McpInvocationError { + #[error("MCP Streamable HTTP session setup failed")] + HttpSetup(#[source] StreamableHttpError), + #[error("MCP Streamable HTTP tool call failed and its outcome is unknown")] + HttpCall(#[source] StreamableHttpError), + #[error("MCP stdio session setup failed")] + StdioSetup(#[source] StdioTransportError), + #[error("MCP stdio tool call failed and its outcome is unknown")] + StdioCall(#[source] StdioTransportError), + #[error("MCP connections are shutting down")] + ShuttingDown, +} + +impl McpInvocationError { + pub fn outcome_unknown(&self) -> bool { + matches!(self, Self::StdioCall(_) | Self::HttpCall(_)) + } +} + +impl McpHttpSourceConfigurationV1 { + fn decode(configuration: &Map) -> Result { + let decoded: Self = decode_configuration(configuration)?; + if decoded.negotiated_protocol_version != HTTP_PROTOCOL_VERSION { + return Err(corrupt_configuration()); + } + validate_endpoint(&decoded.endpoint).map_err(|_| corrupt_configuration())?; + Ok(decoded) + } +} + +impl McpStdioSourceConfigurationV1 { + fn decode(configuration: &Map) -> Result { + let decoded: Self = decode_configuration(configuration)?; + if decoded.template_name.is_empty() + || decoded + .negotiated_protocol_version + .as_deref() + .is_some_and(|version| version != HTTP_PROTOCOL_VERSION) + { + return Err(corrupt_configuration()); + } + Ok(decoded) + } +} + +impl StoredMcpHttpCredentialV1 { + fn decode(stored: &StoredCredential) -> Result { + require_schema(stored, false)?; + let decoded: Self = serde_json::from_value(stored.credential.payload.clone()) + .map_err(|_| corrupt_credentials())?; + validate_endpoint(&decoded.endpoint).map_err(|_| corrupt_credentials())?; + validate_http_credential(decoded.credential.as_ref()).map_err(|_| corrupt_credentials())?; + Ok(decoded) + } + + fn decode_for_invocation(stored: &StoredCredential) -> Result { + require_schema(stored, true)?; + Self::decode(stored) + } + + fn payload(&self) -> Result { + credential_payload(self) + } +} + +impl StoredMcpStdioCredentialV1 { + fn decode(stored: &StoredCredential) -> Result { + require_schema(stored, false)?; + let decoded: Self = serde_json::from_value(stored.credential.payload.clone()) + .map_err(|_| corrupt_credentials())?; + if decoded.template_name.is_empty() { + return Err(corrupt_credentials()); + } + validate_secret_values(&decoded.secret_values).map_err(|_| corrupt_credentials())?; + Ok(decoded) + } + + fn decode_for_invocation(stored: &StoredCredential) -> Result { + require_schema(stored, true)?; + Self::decode(stored) + } + + fn payload(&self) -> Result { + credential_payload(self) + } +} + +async fn execute_http( + config: StreamableHttpConfig, + tool_name: &str, + arguments: Value, +) -> Result { + let transport = StreamableHttpTransport::new(config).map_err(McpInvocationError::HttpSetup)?; + transport + .initialize() + .await + .map_err(McpInvocationError::HttpSetup)?; + let called = transport + .call_tool(tool_name, arguments, json!(1)) + .await + .map_err(McpInvocationError::HttpCall); + let _ = transport.terminate().await; + called.map(mcp_response) +} + +async fn execute_stdio( + templates: &StdioTemplateRegistry, + template_name: &str, + secret_values: &BTreeMap, + tool_name: &str, + arguments: Value, +) -> Result { + let client = templates + .connect_with_secrets( + template_name, + secret_values, + StdioTransportLimits::default(), + ) + .await + .map_err(McpInvocationError::StdioSetup)?; + if let Err(error) = client + .initialize( + STDIO_PROTOCOL_VERSION, + client_info(), + Value::Object(Map::new()), + ) + .await + { + client.shutdown().await; + return Err(McpInvocationError::StdioSetup(error)); + } + let called = client + .call_tool(tool_name, arguments, 2) + .await + .map_err(McpInvocationError::StdioCall); + client.shutdown().await; + called.map(|result| { + mcp_response(json!({ + "content": result.content, + "structuredContent": result.structured_content, + "isError": result.is_error, + })) + }) +} + +fn mcp_response(result: Value) -> ProtocolExecutionResponse { + let is_error = result + .as_object() + .and_then(|result| result.get("isError")) + .and_then(Value::as_bool) + .unwrap_or(false); + ProtocolExecutionResponse { + ok: !is_error, + data: (!is_error).then_some(result), + error: is_error.then_some(ProtocolResponseError { + code: "upstream_tool_error".to_owned(), + message: "The upstream MCP tool returned an error result.".to_owned(), + }), + http: None, + } +} + +async fn discover_http( + stored: &StoredMcpHttpCredentialV1, + allow_private_network: bool, + source_revision: i64, + credential_revision: Option, +) -> Result { + let config = http_transport_config(stored, allow_private_network)?; + let transport = StreamableHttpTransport::new(config).map_err(http_protocol_error)?; + let mut changed = transport.subscribe_tool_list_changed(); + let initialized = transport.initialize().await.map_err(http_protocol_error)?; + let plan = match http_discovery_basis(initialized, source_revision, credential_revision) { + Ok(basis) => discover_http_session(&transport, &mut changed, basis).await, + Err(error) => Err(error), + }; + let _ = transport.terminate().await; + plan +} + +async fn discover_stdio( + templates: &StdioTemplateRegistry, + stored: &StoredMcpStdioCredentialV1, + source_revision: i64, + credential_revision: Option, +) -> Result { + let client = templates + .connect_with_secrets( + &stored.template_name, + &stored.secret_values, + StdioTransportLimits::default(), + ) + .await + .map_err(stdio_protocol_error)?; + let mut changed = client.subscribe_tool_list_changed(); + let initialized = match client + .initialize( + STDIO_PROTOCOL_VERSION, + client_info(), + Value::Object(Map::new()), + ) + .await + { + Ok(initialized) => initialized, + Err(error) => { + client.shutdown().await; + return Err(stdio_protocol_error(error)); + } + }; + let plan = match stdio_discovery_basis(initialized, source_revision, credential_revision) { + Ok(basis) => discover_stdio_session(&client, &mut changed, basis).await, + Err(error) => Err(error), + }; + client.shutdown().await; + plan +} + +async fn discover_http_session( + transport: &StreamableHttpTransport, + changed: &mut tokio::sync::broadcast::Receiver<()>, + basis: DiscoveryBasis, +) -> Result { + tokio::time::timeout(DISCOVERY_DEADLINE, async { + let mut generation = 0_usize; + let mut next_request_id = 1_u64; + loop { + generation += 1; + if generation > MAX_DISCOVERY_GENERATIONS { + return Err(unstable_catalog()); + } + let starting_request_id = next_request_id; + let mut fetcher = HttpPageFetcher { + transport, + next_request_id, + }; + let plan = discover(&mut fetcher, basis.clone()) + .await + .map_err(discovery_protocol_error)?; + next_request_id = fetcher.next_request_id; + if next_request_id == starting_request_id { + return Err(ProtocolError::new( + ProtocolErrorCategory::Internal, + "internal_error", + "MCP discovery did not issue a tools/list request.", + )); + } + if !catalog_changed_before_quiet(changed).await { + return Ok(plan); + } + } + }) + .await + .unwrap_or_else(|_| Err(discovery_timeout())) +} + +async fn discover_stdio_session( + client: &crate::mcp::upstream::stdio::StdioClient, + changed: &mut tokio::sync::broadcast::Receiver<()>, + basis: DiscoveryBasis, +) -> Result { + tokio::time::timeout(DISCOVERY_DEADLINE, async { + for _ in 0..MAX_DISCOVERY_GENERATIONS { + let mut fetcher = StdioPageFetcher { client }; + let plan = discover(&mut fetcher, basis.clone()) + .await + .map(bindings_for_stdio) + .map_err(discovery_protocol_error)?; + if !catalog_changed_before_quiet(changed).await { + return Ok(plan); + } + } + Err(unstable_catalog()) + }) + .await + .unwrap_or_else(|_| Err(discovery_timeout())) +} + +async fn catalog_changed_before_quiet(receiver: &mut tokio::sync::broadcast::Receiver<()>) -> bool { + let mut changed = false; + loop { + match receiver.try_recv() { + Ok(()) | Err(TryRecvError::Lagged(_)) => changed = true, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Closed) => return changed, + } + } + if changed { + return true; + } + match tokio::time::timeout(DISCOVERY_QUIET_PERIOD, receiver.recv()).await { + Ok(Ok(())) | Ok(Err(RecvError::Lagged(_))) => true, + Ok(Err(RecvError::Closed)) | Err(_) => false, + } +} + +fn bindings_for_stdio(plan: DiscoveryPlan) -> DiscoveryPlan { + bindings_for_source_kind(plan, true) +} + +struct HttpPageFetcher<'a> { + transport: &'a StreamableHttpTransport, + next_request_id: u64, +} + +#[async_trait] +impl ToolPageFetcher for HttpPageFetcher<'_> { + type Error = McpPageFetchError; + + async fn fetch_tools_page(&mut self, cursor: Option<&str>) -> Result { + let id = self.next_request_id; + self.next_request_id = self.next_request_id.saturating_add(1); + let page = self + .transport + .list_tools(cursor, json!(id)) + .await + .map_err(McpPageFetchError::Http)?; + let tools = page + .tools + .into_iter() + .map(serde_json::from_value) + .collect::, _>>() + .map_err(|_| McpPageFetchError::Decode)?; + Ok(ToolPage { + tools, + next_cursor: page.next_cursor, + }) + } +} + +struct StdioPageFetcher<'a> { + client: &'a crate::mcp::upstream::stdio::StdioClient, +} + +#[async_trait] +impl ToolPageFetcher for StdioPageFetcher<'_> { + type Error = McpPageFetchError; + + async fn fetch_tools_page(&mut self, cursor: Option<&str>) -> Result { + let page = self + .client + .list_tools(cursor) + .await + .map_err(McpPageFetchError::Stdio)?; + let tools = page + .tools + .into_iter() + .map(|tool| { + let annotations = tool + .annotations + .map(serde_json::from_value) + .transpose() + .map_err(|_| McpPageFetchError::Decode)?; + Ok(DiscoveredMcpTool { + name: tool.name, + title: tool.title, + description: tool.description, + input_schema: tool.input_schema, + output_schema: tool.output_schema, + annotations, + meta: Map::new(), + }) + }) + .collect::, McpPageFetchError>>()?; + Ok(ToolPage { + tools, + next_cursor: page.next_cursor, + }) + } +} + +#[derive(Debug, Error)] +enum McpPageFetchError { + #[error("MCP HTTP tools/list failed")] + Http(#[source] StreamableHttpError), + #[error("MCP stdio tools/list failed")] + Stdio(#[source] StdioTransportError), + #[error("MCP tools/list response could not be decoded")] + Decode, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HttpInitializeResult { + protocol_version: String, + #[serde(default)] + capabilities: Value, + server_info: HttpServerInfo, + #[serde(default)] + instructions: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HttpServerInfo { + name: String, + version: String, + #[serde(default)] + title: Option, +} + +fn http_discovery_basis( + initialized: Value, + expected_source_revision: i64, + expected_credential_revision: Option, +) -> Result { + let initialized: HttpInitializeResult = serde_json::from_value(initialized).map_err(|_| { + ProtocolError::new( + ProtocolErrorCategory::Upstream, + "invalid_mcp_initialize", + "The MCP server returned invalid initialization metadata.", + ) + })?; + if initialized.protocol_version != HTTP_PROTOCOL_VERSION { + return Err(ProtocolError::new( + ProtocolErrorCategory::Upstream, + "mcp_protocol_version_mismatch", + "The MCP server selected an unsupported protocol version.", + )); + } + Ok(DiscoveryBasis { + expected_source_revision, + expected_credential_revision, + protocol_version: initialized.protocol_version, + server_name: initialized.server_info.name, + server_version: initialized.server_info.version, + server_title: initialized.server_info.title, + instructions: initialized.instructions, + capabilities: initialized.capabilities.clone(), + tools_list_changed: tools_list_changed(&initialized.capabilities), + }) +} + +fn stdio_discovery_basis( + initialized: InitializeResult, + expected_source_revision: i64, + expected_credential_revision: Option, +) -> Result { + let server_info = initialized.server_info.ok_or_else(|| { + ProtocolError::new( + ProtocolErrorCategory::Upstream, + "invalid_mcp_initialize", + "The MCP server returned invalid initialization metadata.", + ) + })?; + Ok(DiscoveryBasis { + expected_source_revision, + expected_credential_revision, + protocol_version: initialized.protocol_version.to_string(), + server_name: server_info.name, + server_version: server_info.version, + server_title: server_info.title, + instructions: initialized.instructions, + capabilities: initialized.capabilities.clone(), + tools_list_changed: tools_list_changed(&initialized.capabilities), + }) +} + +fn tools_list_changed(capabilities: &Value) -> bool { + capabilities + .as_object() + .and_then(|capabilities| capabilities.get("tools")) + .and_then(Value::as_object) + .and_then(|tools| tools.get("listChanged")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn client_info() -> Value { + json!({ "name": "executor", "version": env!("CARGO_PKG_VERSION") }) +} + +fn http_transport_config( + stored: &StoredMcpHttpCredentialV1, + allow_private_network: bool, +) -> Result { + validate_http_credential(stored.credential.as_ref())?; + let mut config = StreamableHttpConfig::new(stored.endpoint.clone()); + config.allow_private_networks = allow_private_network; + if let Some(credential) = &stored.credential { + config.headers = credential.headers()?; + } + Ok(config) +} + +fn validate_endpoint(endpoint: &str) -> Result { + if endpoint.is_empty() || endpoint.len() > MAX_ENDPOINT_BYTES { + return Err(ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_mcp_endpoint", + "The MCP endpoint is invalid.", + )); + } + let url = Url::parse(endpoint).map_err(|_| invalid_endpoint())?; + if !matches!(url.scheme(), "http" | "https") + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(invalid_endpoint()); + } + Ok(url) +} + +fn display_endpoint(endpoint: &Url) -> String { + let mut display = endpoint.clone(); + display.set_query(None); + display.to_string() +} + +fn validate_http_credential(credential: Option<&McpHttpCredential>) -> Result<(), ProtocolError> { + if let Some(credential) = credential { + credential.validate()?; + if serde_json::to_vec(credential) + .map(|encoded| encoded.len() > MAX_CREDENTIAL_BYTES) + .unwrap_or(true) + { + return Err(invalid_credentials( + "The MCP credential exceeds the allowed size.", + )); + } + } + Ok(()) +} + +fn validate_secret(secret: &str) -> Result<(), ProtocolError> { + if secret.is_empty() || secret.len() > MAX_SECRET_VALUE_BYTES || secret.as_bytes().contains(&0) + { + Err(invalid_credentials("The MCP credential value is invalid.")) + } else { + Ok(()) + } +} + +fn validate_secret_values(values: &BTreeMap) -> Result<(), ProtocolError> { + if values.len() > MAX_SECRET_VALUES { + return Err(invalid_credentials( + "The MCP stdio source has too many secret values.", + )); + } + for (name, value) in values { + if name.is_empty() + || name.len() > MAX_SECRET_NAME_BYTES + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(invalid_credentials("An MCP stdio secret name is invalid.")); + } + validate_secret(value)?; + } + Ok(()) +} + +fn stdio_secrets_complete( + templates: &StdioTemplateRegistry, + template_name: &str, + values: &BTreeMap, +) -> Result { + let descriptor = templates + .descriptors() + .into_iter() + .find(|descriptor| descriptor.name == template_name) + .ok_or_else(|| input_template_error(StdioTemplateError::UnknownTemplate))?; + if values.is_empty() && !descriptor.secret_fields.is_empty() { + return Ok(false); + } + let exact = values.len() == descriptor.secret_fields.len() + && descriptor + .secret_fields + .iter() + .all(|field| values.contains_key(field)); + if exact { + Ok(true) + } else { + Err(invalid_credentials( + "The MCP stdio secret values must exactly match the selected template.", + )) + } +} + +fn http_credential_metadata( + revision: i64, + credential: &StoredMcpHttpCredentialV1, +) -> CredentialMetadata { + CredentialMetadata { + revision, + configured_schemes: credential + .credential + .as_ref() + .map(|credential| { + vec![ConfiguredCredential { + name: "connection".to_owned(), + credential_type: credential.credential_type(), + }] + }) + .unwrap_or_default(), + } +} + +fn stdio_credential_metadata( + revision: i64, + credential: &StoredMcpStdioCredentialV1, +) -> CredentialMetadata { + CredentialMetadata { + revision, + configured_schemes: credential + .secret_values + .keys() + .map(|name| ConfiguredCredential { + name: name.clone(), + credential_type: "secret_env", + }) + .collect(), + } +} + +async fn required_stored_credential( + catalog: &CatalogStore, + source_id: &str, +) -> Result { + catalog + .credential(source_id) + .await + .map_err(protocol_catalog_error)? + .ok_or_else(|| { + ProtocolError::corrupt( + "source_credentials_missing", + "The source credential state is missing.", + ) + }) +} + +async fn source_supports_list_changed( + catalog: &CatalogStore, + source_id: &str, +) -> Result { + let content = sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts \ + WHERE source_id = ? AND artifact_kind = 'mcp_capabilities' AND stable_key = 'mcp-server'", + ) + .bind(source_id) + .fetch_optional(catalog.pool()) + .await + .map_err(|_| internal_error())? + .ok_or_else(|| { + ProtocolError::corrupt( + "source_artifact_missing", + "The MCP source capability artifact is missing.", + ) + })?; + let content: Value = serde_json::from_str(&content).map_err(|_| corrupt_configuration())?; + Ok(content + .as_object() + .and_then(|content| content.get("toolsListChanged")) + .and_then(Value::as_bool) + .unwrap_or(false)) +} + +fn credential_payload(value: &T) -> Result { + Ok(CredentialPayload { + schema_version: MCP_CREDENTIAL_SCHEMA_VERSION, + payload: serde_json::to_value(value).map_err(|_| internal_error())?, + }) +} + +fn encode_configuration(value: &T) -> Result, ProtocolError> { + serde_json::to_value(value) + .map_err(|_| internal_error())? + .as_object() + .cloned() + .ok_or_else(internal_error) +} + +fn decode_configuration Deserialize<'de>>( + configuration: &Map, +) -> Result { + serde_json::from_value(Value::Object(configuration.clone())) + .map_err(|_| corrupt_configuration()) +} + +fn require_schema(stored: &StoredCredential, invocation: bool) -> Result<(), ProtocolError> { + if stored.credential.schema_version == MCP_CREDENTIAL_SCHEMA_VERSION { + Ok(()) + } else if invocation { + Err(ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "unsupported_credential_schema", + "The stored MCP credential schema is not supported.", + )) + } else { + Err(ProtocolError::corrupt( + "unsupported_credential_schema", + "The stored MCP credential schema is not supported.", + )) + } +} + +fn require_revision(expected: i64, actual: i64) -> Result<(), ProtocolError> { + if expected == actual { + Ok(()) + } else { + Err(ProtocolError::new( + ProtocolErrorCategory::Conflict, + "revision_conflict", + "The source changed. Refresh and retry the update.", + )) + } +} + +fn discovery_protocol_error(error: DiscoveryError) -> ProtocolError { + let (code, message) = match error { + DiscoveryError::Fetch(_) => ( + "mcp_discovery_failed", + "The MCP server failed while listing tools.", + ), + DiscoveryError::TooManyPages { .. } => ( + "mcp_too_many_pages", + "The MCP server returned too many tool pages.", + ), + DiscoveryError::TooManyTools { .. } => ( + "mcp_too_many_tools", + "The MCP server returned too many tools.", + ), + DiscoveryError::InvalidCursor | DiscoveryError::CursorCycle => ( + "invalid_mcp_cursor", + "The MCP server returned invalid tool pagination.", + ), + DiscoveryError::DuplicateTool { .. } => ( + "duplicate_mcp_tool", + "The MCP server returned duplicate tool names.", + ), + DiscoveryError::InvalidTool { .. } => ( + "invalid_mcp_tool", + "The MCP server returned an invalid tool definition.", + ), + DiscoveryError::CapabilitiesTooLarge => ( + "mcp_capabilities_too_large", + "The MCP server returned too much capability metadata.", + ), + DiscoveryError::InvalidCapabilities => ( + "invalid_mcp_capabilities", + "The MCP server returned invalid capabilities.", + ), + DiscoveryError::ToolMetadataTooLarge => ( + "mcp_tool_metadata_too_large", + "The MCP server returned too much tool metadata.", + ), + }; + ProtocolError::new(ProtocolErrorCategory::Upstream, code, message) +} + +fn http_configuration_error(error: StreamableHttpError) -> ProtocolError { + match error { + StreamableHttpError::Outbound(error) => super::protocol_outbound_error(error), + _ => invalid_credentials("The MCP HTTP credential configuration is invalid."), + } +} + +fn http_protocol_error(error: StreamableHttpError) -> ProtocolError { + match error { + StreamableHttpError::Outbound(error) => super::protocol_outbound_error(error), + StreamableHttpError::ProtocolVersionMismatch => ProtocolError::new( + ProtocolErrorCategory::Upstream, + "mcp_protocol_version_mismatch", + "The MCP server selected an unsupported protocol version.", + ), + _ => ProtocolError::new( + ProtocolErrorCategory::Upstream, + "mcp_transport_error", + "The MCP HTTP server could not be reached or returned an invalid response.", + ), + } +} + +fn stdio_protocol_error(_error: StdioTransportError) -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Upstream, + "mcp_transport_error", + "The MCP stdio server could not be started or returned an invalid response.", + ) +} + +fn shutting_down(_error: crate::mcp::manager::McpShuttingDown) -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Conflict, + "mcp_shutting_down", + "MCP connections are shutting down.", + ) +} + +fn unstable_catalog() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Upstream, + "mcp_catalog_unstable", + "The MCP tool catalog kept changing during discovery.", + ) +} + +fn discovery_timeout() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Upstream, + "mcp_discovery_timeout", + "MCP tool discovery exceeded its deadline.", + ) +} + +fn stale_watcher_reconciliation() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Conflict, + "stale_watcher_reconciliation", + "The MCP source changed while its watcher was reconciling.", + ) +} + +fn input_template_error(_error: StdioTemplateError) -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_stdio_template", + "The selected MCP stdio template is unavailable.", + ) +} + +fn stored_template_error(_error: StdioTemplateError) -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Conflict, + "stdio_template_unavailable", + "The configured MCP stdio template is unavailable.", + ) +} + +fn invalid_endpoint() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_mcp_endpoint", + "The MCP endpoint must be an absolute HTTP or HTTPS URL without user info or a fragment.", + ) +} + +fn invalid_credentials(message: &'static str) -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::InvalidInput, + "invalid_credentials", + message, + ) +} + +fn corrupt_configuration() -> ProtocolError { + ProtocolError::corrupt( + "invalid_source_configuration", + "The stored MCP source configuration is invalid.", + ) +} + +fn corrupt_credentials() -> ProtocolError { + ProtocolError::corrupt( + "invalid_source_credentials", + "The stored MCP credential state is invalid.", + ) +} + +fn internal_error() -> ProtocolError { + ProtocolError::new( + ProtocolErrorCategory::Internal, + "internal_error", + "The MCP protocol operation could not be completed.", + ) +} + +#[cfg(test)] +mod tests { + use std::{collections::VecDeque, convert::Infallible}; + + use super::*; + use crate::{ + AppConfig, ExecutorApp, + catalog::{CreateSource, CredentialPayload, ListToolsFilter, StoredCredential}, + }; + + struct ReconciliationFetcher { + pages: VecDeque, + events: Arc>>, + session: &'static str, + generation: usize, + } + + #[async_trait] + impl ToolPageFetcher for ReconciliationFetcher { + type Error = Infallible; + + async fn fetch_tools_page( + &mut self, + cursor: Option<&str>, + ) -> Result { + let page = if cursor.is_some() { 2 } else { 1 }; + self.events + .lock() + .expect("event mutex is available") + .push(format!( + "{}:discover-{}-page-{page}", + self.session, self.generation + )); + Ok(self.pages.pop_front().expect("discovery page exists")) + } + } + + fn reconciliation_basis(expected_source_revision: i64) -> DiscoveryBasis { + DiscoveryBasis { + expected_source_revision, + expected_credential_revision: Some(0), + protocol_version: HTTP_PROTOCOL_VERSION.to_owned(), + server_name: "fixture".to_owned(), + server_version: "1".to_owned(), + server_title: None, + instructions: None, + capabilities: json!({ "tools": { "listChanged": true } }), + tools_list_changed: true, + } + } + + fn reconciliation_tool(name: &str) -> DiscoveredMcpTool { + DiscoveredMcpTool { + name: name.to_owned(), + title: None, + description: None, + input_schema: json!({ "type": "object" }), + output_schema: None, + annotations: None, + meta: Map::new(), + } + } + + async fn reconciliation_plan(expected_source_revision: i64, name: &str) -> DiscoveryPlan { + let mut fetcher = ReconciliationFetcher { + pages: VecDeque::from([ToolPage { + tools: vec![reconciliation_tool(name)], + next_cursor: None, + }]), + events: Arc::new(std::sync::Mutex::new(Vec::new())), + session: "plan", + generation: 1, + }; + discover(&mut fetcher, reconciliation_basis(expected_source_revision)) + .await + .expect("test discovery succeeds") + } + + #[test] + fn strict_create_inputs_reject_unknown_fields_and_default_private_access_off() { + let input: CreateMcpHttpSource = serde_json::from_value(json!({ + "displayName": "Example", + "endpoint": "https://example.com/mcp" + })) + .expect("minimal HTTP input decodes"); + assert!(!input.allow_private_network); + assert!(input.credential.is_none()); + + assert!( + serde_json::from_value::(json!({ + "displayName": "Example", + "endpoint": "https://example.com/mcp", + "unexpected": true + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "displayName": "Example", + "templateName": "fixture", + "secretValues": {}, + "executable": "/bin/sh" + })) + .is_err() + ); + } + + #[test] + fn public_http_configuration_removes_query_secrets() { + let endpoint = validate_endpoint("https://example.com/mcp?api_key=secret") + .expect("endpoint validates"); + assert_eq!(display_endpoint(&endpoint), "https://example.com/mcp"); + let stored = StoredMcpHttpCredentialV1 { + endpoint: endpoint.to_string(), + credential: None, + }; + assert!(stored.endpoint.contains("api_key=secret")); + } + + #[test] + fn credentials_validate_and_metadata_never_contains_values() { + let credential = McpHttpCredential::Bearer { + token: "highly-secret".to_owned(), + }; + credential.validate().expect("bearer validates"); + let stored = StoredMcpHttpCredentialV1 { + endpoint: "https://example.com/mcp".to_owned(), + credential: Some(credential), + }; + let metadata = http_credential_metadata(3, &stored); + assert_eq!(metadata.revision, 3); + assert_eq!(metadata.configured_schemes[0].credential_type, "bearer"); + assert!(!format!("{metadata:?}").contains("highly-secret")); + + let stdio = StoredMcpStdioCredentialV1 { + template_name: "fixture".to_owned(), + secret_values: BTreeMap::from([("API_TOKEN".to_owned(), "hidden".to_owned())]), + }; + let metadata = stdio_credential_metadata(5, &stdio); + assert_eq!(metadata.configured_schemes[0].name, "API_TOKEN"); + assert_eq!(metadata.configured_schemes[0].credential_type, "secret_env"); + assert!(!format!("{metadata:?}").contains("hidden")); + } + + #[test] + fn api_key_credentials_reject_protected_transport_headers() { + for name in [ + "Authorization", + "Host", + "Content-Type", + "Accept", + "Content-Length", + "Connection", + "Transfer-Encoding", + "Mcp-Session-Id", + "Mcp-Protocol-Version", + "Origin", + "Referer", + "Proxy-Authorization", + "Proxy-Custom", + ] { + let credential = McpHttpCredential::ApiKeyHeader { + name: name.to_owned(), + value: "secret".to_owned(), + }; + assert!(credential.validate().is_err(), "{name} must be protected"); + } + McpHttpCredential::ApiKeyHeader { + name: "X-Api-Key".to_owned(), + value: "secret".to_owned(), + } + .validate() + .expect("an ordinary API key header validates"); + } + + #[test] + fn stdio_secret_completeness_allows_deferred_create_but_requires_exact_rotation() { + let registry = + StdioTemplateRegistry::new(vec![crate::mcp::upstream::stdio::StdioTemplate { + name: "fixture".to_owned(), + executable: std::path::PathBuf::from("/bin/sh"), + cwd: None, + arguments: Vec::new(), + environment: BTreeMap::new(), + secret_environment: vec!["API_TOKEN".to_owned()], + }]) + .expect("fixture registry validates"); + assert!( + !stdio_secrets_complete(®istry, "fixture", &BTreeMap::new()) + .expect("omitted secrets select deferred creation") + ); + assert!( + stdio_secrets_complete( + ®istry, + "fixture", + &BTreeMap::from([("API_TOKEN".to_owned(), " secret ".to_owned())]), + ) + .expect("an exact secret overlay is complete") + ); + assert!( + stdio_secrets_complete( + ®istry, + "fixture", + &BTreeMap::from([("OTHER".to_owned(), "secret".to_owned())]), + ) + .is_err() + ); + + let public_registry = + StdioTemplateRegistry::new(vec![crate::mcp::upstream::stdio::StdioTemplate { + name: "public-fixture".to_owned(), + executable: std::path::PathBuf::from("/bin/sh"), + cwd: None, + arguments: Vec::new(), + environment: BTreeMap::new(), + secret_environment: Vec::new(), + }]) + .expect("public fixture registry validates"); + assert!( + stdio_secrets_complete(&public_registry, "public-fixture", &BTreeMap::new()) + .expect("an empty overlay is complete for a template without secrets") + ); + } + + #[test] + fn malformed_persisted_credentials_fail_closed() { + let stored = StoredCredential { + revision: 1, + credential: CredentialPayload { + schema_version: MCP_CREDENTIAL_SCHEMA_VERSION, + payload: json!({ + "endpoint": "file:///tmp/mcp", + "credential": null + }), + }, + }; + let Err(error) = StoredMcpHttpCredentialV1::decode(&stored) else { + panic!("invalid stored endpoint must fail closed"); + }; + assert_eq!(error.code, "invalid_source_credentials"); + assert_eq!(error.category, ProtocolErrorCategory::CorruptData); + } + + #[test] + fn preparation_is_network_free_and_preserves_secret_values() { + let adapter = McpAdapter::default(); + let configuration = json!({ + "endpoint": "https://example.com/mcp", + "allowPrivateNetwork": false, + "negotiatedProtocolVersion": HTTP_PROTOCOL_VERSION + }) + .as_object() + .expect("configuration is an object") + .clone(); + let stored = StoredCredential { + revision: 1, + credential: StoredMcpHttpCredentialV1 { + endpoint: "https://example.com/mcp?secret=query".to_owned(), + credential: Some(McpHttpCredential::Bearer { + token: "token".to_owned(), + }), + } + .payload() + .expect("credential encodes"), + }; + let prepared = adapter + .prepare_invocation( + SourceKind::McpHttp, + &McpToolBindingV1 { + version: 1, + tool_name: "ping".to_owned(), + }, + &configuration, + Some(&stored), + &json!({}), + ) + .expect("preparation succeeds without transport I/O"); + let PreparedMcpInvocation::Http { config, .. } = prepared else { + panic!("HTTP invocation is prepared") + }; + assert!(config.endpoint.contains("secret=query")); + assert!(config.headers.contains_key(AUTHORIZATION)); + } + + #[test] + fn mcp_error_results_are_not_exposed_as_success_data() { + let response = mcp_response(json!({ + "content": [{ "type": "text", "text": "failed" }], + "isError": true + })); + assert!(!response.ok); + assert!(response.data.is_none()); + assert_eq!( + response.error.expect("error metadata exists").code, + "upstream_tool_error" + ); + } + + #[tokio::test] + async fn discovery_change_signals_coalesce_and_require_a_quiet_period() { + let (sender, mut receiver) = tokio::sync::broadcast::channel(2); + sender.send(()).expect("first signal sends"); + sender.send(()).expect("second signal sends"); + sender.send(()).expect("lagging signal sends"); + assert!(catalog_changed_before_quiet(&mut receiver).await); + assert!(!catalog_changed_before_quiet(&mut receiver).await); + } + + #[tokio::test] + async fn watcher_reconciliation_gate_finishes_full_discovery_before_steady_state() { + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + + for session in ["startup", "reconnect"] { + let reconcile_events = events.clone(); + let reconciled = reconcile_watcher_session(async move { + for generation in 1..=2 { + let mut fetcher = ReconciliationFetcher { + pages: VecDeque::from([ + ToolPage { + tools: vec![reconciliation_tool("one")], + next_cursor: Some("next".to_owned()), + }, + ToolPage { + tools: vec![reconciliation_tool("two")], + next_cursor: None, + }, + ]), + events: reconcile_events.clone(), + session, + generation, + }; + discover(&mut fetcher, reconciliation_basis(1)) + .await + .expect("full paginated discovery succeeds"); + } + Ok::<(), Infallible>(()) + }) + .await + .expect("reconciliation reaches the steady-state gate"); + let () = reconciled.into_inner(); + events + .lock() + .expect("event mutex is available") + .push(format!("{session}:steady-state")); + } + + assert_eq!( + *events.lock().expect("event mutex is available"), + [ + "startup:discover-1-page-1", + "startup:discover-1-page-2", + "startup:discover-2-page-1", + "startup:discover-2-page-2", + "startup:steady-state", + "reconnect:discover-1-page-1", + "reconnect:discover-1-page-2", + "reconnect:discover-2-page-1", + "reconnect:discover-2-page-2", + "reconnect:steady-state", + ] + ); + } + + #[tokio::test] + async fn immediate_http_405_is_observed_only_after_startup_reconciliation_commits() { + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let listener_events = events.clone(); + let mut listener = tokio::spawn(async move { + listener_events + .lock() + .expect("event mutex is available") + .push("listener-405".to_owned()); + Err(StreamableHttpError::HttpStatus( + reqwest::StatusCode::METHOD_NOT_ALLOWED, + )) + }); + tokio::task::yield_now().await; + assert!(listener.is_finished()); + + let reconciliation_events = events.clone(); + let reconciled = reconcile_watcher_session(async move { + let mut fetcher = ReconciliationFetcher { + pages: VecDeque::from([ToolPage { + tools: vec![reconciliation_tool("startup")], + next_cursor: None, + }]), + events: reconciliation_events.clone(), + session: "startup-405", + generation: 1, + }; + let plan = discover(&mut fetcher, reconciliation_basis(1)) + .await + .expect("startup discovery succeeds"); + reconciliation_events + .lock() + .expect("event mutex is available") + .push("catalog-commit".to_owned()); + Ok::<_, Infallible>(plan) + }) + .await + .expect("startup reconciliation completes"); + + let listener_result = + finished_http_listener_after_reconciliation(&reconciled, &mut listener) + .await + .expect("the completed listener is observed after reconciliation") + .expect("listener task joins"); + assert!(matches!( + listener_result, + Err(StreamableHttpError::HttpStatus(status)) + if status == reqwest::StatusCode::METHOD_NOT_ALLOWED + )); + events + .lock() + .expect("event mutex is available") + .push("405-honored".to_owned()); + + assert_eq!( + *events.lock().expect("event mutex is available"), + [ + "listener-405", + "startup-405:discover-1-page-1", + "catalog-commit", + "405-honored", + ] + ); + } + + #[tokio::test] + async fn stale_catalog_cas_does_not_publish_a_watcher_plan() { + let directory = tempfile::tempdir().expect("temporary data directory exists"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test app opens"); + let initial = reconciliation_plan(0, "initial").await; + let (source, _) = app + .catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::McpHttp, + preferred_slug: "watcher-cas".to_owned(), + display_name: "Watcher CAS".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: MCP_CREDENTIAL_SCHEMA_VERSION, + payload: json!({}), + }, + initial.initial_catalog_snapshot(), + initial.bindings, + AuditContext::system(Some("watcher-cas-create")), + ) + .await + .expect("source is created"); + let manager = Arc::new(McpConnectionManager::new(StdioTemplateRegistry::default())); + let (lease_sender, lease_receiver) = tokio::sync::oneshot::channel(); + assert!( + manager + .replace_watcher( + source.id.clone(), + source.revision, + move |mut canceled, lease| async move { + lease_sender.send(lease).ok(); + let _ = (&mut canceled).await; + }, + ) + .await + .expect("manager accepts watcher") + ); + let lease = tokio::time::timeout(Duration::from_secs(2), lease_receiver) + .await + .expect("watcher delivers its lease promptly") + .expect("watcher exposes its lease"); + let fresh = reconciliation_plan(source.revision, "fresh").await; + let fresh_result = app + .catalog() + .sync_catalog_with_bindings( + &source.id, + fresh.catalog_snapshot(), + fresh.bindings, + AuditContext::system(Some("watcher-cas-fresh")), + ) + .await + .expect("concurrent catalog update commits"); + let stale = reconciliation_plan(source.revision, "stale").await; + let revisions = std::sync::atomic::AtomicI64::new(source.revision); + + let error = commit_watcher_discovery(app.catalog(), &source.id, stale, &lease, &revisions) + .await + .expect_err("stale watcher CAS is rejected"); + assert_eq!(error.code, "revision_conflict"); + assert_eq!( + app.catalog() + .source(&source.id) + .await + .expect("source remains readable") + .revision, + fresh_result.source_revision + ); + let tools = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + include_tombstoned: true, + limit: 100, + ..ListToolsFilter::default() + }) + .await + .expect("catalog remains readable"); + assert!(tools.items.iter().any(|tool| tool.stable_key == "fresh")); + assert!(!tools.items.iter().any(|tool| tool.stable_key == "stale")); + + manager.shutdown().await; + app.shutdown().await; + } + + #[tokio::test] + async fn stale_watcher_generation_cannot_publish_at_the_current_catalog_revision() { + let directory = tempfile::tempdir().expect("temporary data directory exists"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test app opens"); + let initial = reconciliation_plan(0, "initial").await; + let (source, _) = app + .catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::McpHttp, + preferred_slug: "watcher-generation".to_owned(), + display_name: "Watcher generation".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: MCP_CREDENTIAL_SCHEMA_VERSION, + payload: json!({}), + }, + initial.initial_catalog_snapshot(), + initial.bindings, + AuditContext::system(Some("watcher-generation-create")), + ) + .await + .expect("source is created"); + let manager = Arc::new(McpConnectionManager::new(StdioTemplateRegistry::default())); + let (stale_sender, stale_receiver) = tokio::sync::oneshot::channel(); + assert!( + manager + .replace_watcher( + source.id.clone(), + source.revision, + move |mut canceled, lease| async move { + stale_sender.send(lease).ok(); + let _ = (&mut canceled).await; + }, + ) + .await + .expect("first watcher installs") + ); + let stale_lease = tokio::time::timeout(Duration::from_secs(2), stale_receiver) + .await + .expect("first watcher delivers its lease promptly") + .expect("first watcher exposes its lease"); + assert!( + manager + .replace_watcher( + source.id.clone(), + source.revision, + move |mut canceled, _lease| async move { + let _ = (&mut canceled).await; + }, + ) + .await + .expect("replacement watcher installs") + ); + let stale_plan = reconciliation_plan(source.revision, "stale-generation").await; + let revisions = std::sync::atomic::AtomicI64::new(source.revision); + + let error = commit_watcher_discovery( + app.catalog(), + &source.id, + stale_plan, + &stale_lease, + &revisions, + ) + .await + .expect_err("replaced watcher cannot publish at the current revision"); + assert_eq!(error.code, "stale_watcher_reconciliation"); + assert_eq!( + app.catalog() + .source(&source.id) + .await + .expect("source remains readable") + .revision, + source.revision + ); + let tools = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + include_tombstoned: true, + limit: 100, + ..ListToolsFilter::default() + }) + .await + .expect("catalog remains readable"); + assert!(tools.items.iter().any(|tool| tool.stable_key == "initial")); + assert!( + !tools + .items + .iter() + .any(|tool| tool.stable_key == "stale-generation") + ); + + manager.shutdown().await; + app.shutdown().await; + } + + #[tokio::test] + async fn watcher_lease_advancement_fences_a_stale_generation() { + let manager = Arc::new(McpConnectionManager::new(StdioTemplateRegistry::default())); + let (first_sender, first_receiver) = tokio::sync::oneshot::channel(); + assert!( + manager + .replace_watcher( + "source".to_owned(), + 7, + move |mut canceled, lease| async move { + first_sender.send(lease).ok(); + let _ = (&mut canceled).await; + } + ) + .await + .expect("first watcher installs") + ); + let stale = tokio::time::timeout(Duration::from_secs(2), first_receiver) + .await + .expect("first watcher delivers its lease promptly") + .expect("first lease is available"); + let (current_sender, current_receiver) = tokio::sync::oneshot::channel(); + assert!( + manager + .replace_watcher( + "source".to_owned(), + 7, + move |mut canceled, lease| async move { + current_sender.send(lease).ok(); + let _ = (&mut canceled).await; + } + ) + .await + .expect("replacement watcher installs") + ); + let current = tokio::time::timeout(Duration::from_secs(2), current_receiver) + .await + .expect("replacement watcher delivers its lease promptly") + .expect("replacement lease is available"); + + assert!(stale.lock_revision(7).await.is_none()); + let current_guard = current + .lock_revision(7) + .await + .expect("current watcher locks its revision"); + assert!(current_guard.advance(8)); + assert!(stale.lock_revision(8).await.is_none()); + assert!(manager.stop_watcher_and_wait_at_revision("source", 8).await); + manager.shutdown().await; + } + + #[tokio::test] + async fn buffered_list_changed_notifications_use_one_refresh_runner() { + let (sender, mut receiver) = tokio::sync::broadcast::channel(4); + let coalescer = ListChangedCoalescer::default(); + sender.send(()).expect("notification is buffered"); + drain_change_signals(&mut receiver, &coalescer); + assert!(coalescer.has_pending()); + + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let in_flight = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let maximum_in_flight = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let runner = { + let coalescer = coalescer.clone(); + let started = started.clone(); + let release = release.clone(); + let calls = calls.clone(); + let in_flight = in_flight.clone(); + let maximum_in_flight = maximum_in_flight.clone(); + tokio::spawn(async move { + coalescer + .refresh_pending(|| { + let started = started.clone(); + let release = release.clone(); + let calls = calls.clone(); + let in_flight = in_flight.clone(); + let maximum_in_flight = maximum_in_flight.clone(); + async move { + let active = + in_flight.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + maximum_in_flight + .fetch_max(active, std::sync::atomic::Ordering::SeqCst); + let call = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + if call == 1 { + started.notify_one(); + release.notified().await; + } + in_flight.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + Ok::<(), Infallible>(()) + } + }) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(2), started.notified()) + .await + .expect("refresh runner starts promptly"); + sender.send(()).expect("second notification is buffered"); + drain_change_signals(&mut receiver, &coalescer); + assert_eq!( + coalescer + .refresh_pending(|| async { Ok::<(), Infallible>(()) }) + .await + .expect("competing runner exits cleanly"), + 0 + ); + release.notify_one(); + + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), runner) + .await + .expect("refresh runner completes promptly") + .expect("refresh task joins") + .expect("refreshes succeed"), + 2 + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!( + maximum_in_flight.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + assert!(!coalescer.has_pending()); + } + + #[tokio::test] + async fn http_listener_disconnect_interrupts_refresh_discovery_and_retry_backoff() { + let (_cancel, mut canceled) = tokio::sync::oneshot::channel(); + let refresh_started = Arc::new(tokio::sync::Notify::new()); + let mut listener = { + let refresh_started = refresh_started.clone(); + tokio::spawn(async move { + refresh_started.notified().await; + Err::<(), _>(StreamableHttpError::InvalidResponse) + }) + }; + let refresh = async { + refresh_started.notify_one(); + std::future::pending::>().await + }; + tokio::pin!(refresh); + + match tokio::time::timeout( + Duration::from_secs(2), + wait_for_watcher_refresh(&mut canceled, &mut listener, &mut refresh), + ) + .await + .expect("HTTP listener disconnect wins promptly") + { + WatcherRefreshWait::Disconnected(Ok(Err(StreamableHttpError::InvalidResponse))) => {} + _ => panic!("HTTP refresh discovery must yield to listener disconnect"), + } + + let mut listener = + tokio::spawn(async { Err::<(), _>(StreamableHttpError::InvalidResponse) }); + match tokio::time::timeout( + Duration::from_secs(2), + wait_for_watcher_refresh( + &mut canceled, + &mut listener, + tokio::time::sleep(Duration::from_secs(60)), + ), + ) + .await + .expect("HTTP listener disconnect interrupts backoff promptly") + { + WatcherRefreshWait::Disconnected(Ok(Err(StreamableHttpError::InvalidResponse))) => {} + _ => panic!("HTTP retry backoff must yield to listener disconnect"), + } + } + + #[tokio::test] + async fn stdio_lifecycle_disconnect_interrupts_refresh_discovery_and_retry_backoff() { + let (_cancel, mut canceled) = tokio::sync::oneshot::channel(); + let lifecycle = StdioLifecycleMonitor::initialized_fixture(); + let client_lock = tokio::sync::Mutex::new(()); + let client_guard = client_lock.lock().await; + let refresh_started = Arc::new(tokio::sync::Notify::new()); + let disconnect = tokio::spawn({ + let refresh_started = refresh_started.clone(); + let lifecycle = lifecycle.clone(); + async move { + refresh_started.notified().await; + lifecycle.disconnect_fixture(); + } + }); + let refresh = async { + refresh_started.notify_one(); + std::future::pending::>().await + }; + + match tokio::time::timeout( + Duration::from_secs(2), + wait_for_watcher_refresh( + &mut canceled, + wait_for_stdio_disconnect(&lifecycle), + refresh, + ), + ) + .await + .expect("stdio disconnect wins promptly") + { + WatcherRefreshWait::Disconnected(()) => {} + _ => panic!("stdio refresh discovery must yield to lifecycle disconnect"), + } + disconnect.await.expect("disconnect fixture task joins"); + assert!(client_lock.try_lock().is_err()); + drop(client_guard); + + let lifecycle = StdioLifecycleMonitor::initialized_fixture(); + lifecycle.disconnect_fixture(); + match tokio::time::timeout( + Duration::from_secs(2), + wait_for_watcher_refresh( + &mut canceled, + wait_for_stdio_disconnect(&lifecycle), + tokio::time::sleep(Duration::from_secs(60)), + ), + ) + .await + .expect("stdio disconnect interrupts backoff promptly") + { + WatcherRefreshWait::Disconnected(()) => {} + _ => panic!("stdio retry backoff must yield to lifecycle disconnect"), + } + } + + #[test] + fn every_http_call_transport_failure_is_outcome_unknown() { + let error = McpInvocationError::HttpCall(StreamableHttpError::InvalidResponse); + assert!(error.outcome_unknown()); + let setup = McpInvocationError::HttpSetup(StreamableHttpError::InvalidResponse); + assert!(!setup.outcome_unknown()); + + let stdio_call = McpInvocationError::StdioCall(StdioTransportError::Decode); + assert!(stdio_call.outcome_unknown()); + let stdio_setup = McpInvocationError::StdioSetup(StdioTransportError::Decode); + assert!(!stdio_setup.outcome_unknown()); + } +} diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 2f6fefe49..70ba478d9 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -1,6 +1,7 @@ +pub mod mcp; pub mod openapi; -use std::{collections::BTreeMap, fmt}; +use std::{collections::BTreeMap, fmt, sync::Arc}; use serde::Serialize; use serde::de::DeserializeOwned; @@ -45,17 +46,20 @@ pub struct PreparedProtocolInvocation { enum PreparedProtocolExecution { OpenApi(openapi::PreparedOpenApiInvocation), + Mcp(mcp::PreparedMcpInvocation), } #[derive(Debug)] pub enum ProtocolInvocationError { Outbound(OutboundError), + Mcp(mcp::McpInvocationError), } impl fmt::Display for ProtocolInvocationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Outbound(error) => error.fmt(formatter), + Self::Mcp(error) => error.fmt(formatter), } } } @@ -64,6 +68,7 @@ impl std::error::Error for ProtocolInvocationError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Outbound(error) => Some(error), + Self::Mcp(error) => Some(error), } } } @@ -130,31 +135,47 @@ impl std::error::Error for ProtocolError {} pub enum ProtocolRegistration { OpenApi, GraphqlUnsupported, - McpHttpUnsupported, - McpStdioUnsupported, + McpHttp, + McpStdio, } -#[derive(Clone, Default)] +#[derive(Clone)] pub struct ProtocolRegistry { openapi: openapi::OpenApiAdapter, + mcp: mcp::McpAdapter, +} + +impl Default for ProtocolRegistry { + fn default() -> Self { + Self::new(Arc::new(crate::mcp::manager::McpConnectionManager::new( + crate::mcp::upstream::stdio::StdioTemplateRegistry::default(), + ))) + } } impl ProtocolRegistry { + pub(crate) fn new(connections: Arc) -> Self { + Self { + openapi: openapi::OpenApiAdapter, + mcp: mcp::McpAdapter::with_connection_manager(connections), + } + } + pub fn registration(&self, kind: SourceKind) -> ProtocolRegistration { match kind { SourceKind::Openapi => ProtocolRegistration::OpenApi, SourceKind::Graphql => ProtocolRegistration::GraphqlUnsupported, - SourceKind::McpHttp => ProtocolRegistration::McpHttpUnsupported, - SourceKind::McpStdio => ProtocolRegistration::McpStdioUnsupported, + SourceKind::McpHttp => ProtocolRegistration::McpHttp, + SourceKind::McpStdio => ProtocolRegistration::McpStdio, } } fn require_supported(&self, kind: SourceKind) -> Result<(), ProtocolError> { match self.registration(kind) { - ProtocolRegistration::OpenApi => Ok(()), - ProtocolRegistration::GraphqlUnsupported - | ProtocolRegistration::McpHttpUnsupported - | ProtocolRegistration::McpStdioUnsupported => Err(ProtocolError::new( + ProtocolRegistration::OpenApi + | ProtocolRegistration::McpHttp + | ProtocolRegistration::McpStdio => Ok(()), + ProtocolRegistration::GraphqlUnsupported => Err(ProtocolError::new( ProtocolErrorCategory::Unsupported, "unsupported_source_kind", "This source protocol is not supported yet.", @@ -183,7 +204,28 @@ impl ProtocolRegistry { arguments, )?) } - SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + SourceKind::McpHttp | SourceKind::McpStdio => { + let binding = match (lease.source_kind(), lease.binding()) { + (SourceKind::McpHttp, crate::catalog::ToolBinding::McpHttpV1(binding)) + | (SourceKind::McpStdio, crate::catalog::ToolBinding::McpStdioV1(binding)) => { + binding + } + _ => { + return Err(ProtocolError::corrupt( + "source_binding_mismatch", + "The stored tool binding does not match its source protocol.", + )); + } + }; + PreparedProtocolExecution::Mcp(self.mcp.prepare_invocation( + lease.source_kind(), + binding, + lease.source_configuration(), + lease.credential(), + arguments, + )?) + } + SourceKind::Graphql => { return Err(ProtocolError::corrupt( "source_binding_mismatch", "The stored tool binding does not match its source protocol.", @@ -202,6 +244,11 @@ impl ProtocolRegistry { PreparedProtocolExecution::OpenApi(prepared) => { self.openapi.execute_invocation(prepared).await } + PreparedProtocolExecution::Mcp(prepared) => self + .mcp + .execute_invocation(prepared) + .await + .map_err(ProtocolInvocationError::Mcp), }; drop(lease); response @@ -215,10 +262,13 @@ pub struct SourceService { } impl SourceService { - pub fn new(catalog: CatalogStore) -> Self { + pub(crate) fn new( + catalog: CatalogStore, + connections: Arc, + ) -> Self { Self { catalog, - registry: ProtocolRegistry::default(), + registry: ProtocolRegistry::new(connections), } } @@ -226,6 +276,41 @@ impl SourceService { &self.registry } + pub fn stdio_template_descriptors( + &self, + ) -> Vec { + self.registry.mcp.stdio_templates().descriptors() + } + + pub async fn restore_mcp_watchers(&self) -> Result<(), ProtocolError> { + self.registry.mcp.restore_watchers(&self.catalog).await + } + + pub async fn delete( + &self, + source_id: &str, + audit: AuditContext<'_>, + ) -> Result<(), ProtocolError> { + let source = self.source(source_id).await?; + let is_mcp = matches!(source.kind, SourceKind::McpHttp | SourceKind::McpStdio); + if is_mcp { + self.registry.mcp.retire_source_watcher(source_id).await; + } + let deleted = self + .catalog + .delete_source(source_id, audit) + .await + .map_err(protocol_catalog_error); + if deleted.is_err() && is_mcp { + self.registry.mcp.unretire_source_watcher(source_id).await; + self.registry + .mcp + .restore_source_watcher(&self.catalog, &source) + .await; + } + deleted + } + pub async fn preview_openapi( &self, spec: &OpenApiSpecInput, @@ -252,7 +337,21 @@ impl SourceService { .create_source(&self.catalog, input, audit) .await } - SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + SourceKind::McpHttp => { + let input = decode_protocol_input(protocol)?; + self.registry + .mcp + .create_http_source(&self.catalog, input, audit) + .await + } + SourceKind::McpStdio => { + let input = decode_protocol_input(protocol)?; + self.registry + .mcp + .create_stdio_source(&self.catalog, input, audit) + .await + } + SourceKind::Graphql => { unreachable!("unsupported protocols return before create dispatch") } } @@ -272,7 +371,13 @@ impl SourceService { .refresh_source(&self.catalog, source, audit) .await } - SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + SourceKind::McpHttp | SourceKind::McpStdio => { + self.registry + .mcp + .refresh_source(&self.catalog, source, audit) + .await + } + SourceKind::Graphql => { unreachable!("unsupported protocols return before refresh dispatch") } } @@ -291,7 +396,13 @@ impl SourceService { .credential_metadata(&self.catalog, &source.id) .await } - SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + SourceKind::McpHttp | SourceKind::McpStdio => { + self.registry + .mcp + .credential_metadata(&self.catalog, &source) + .await + } + SourceKind::Graphql => { unreachable!("unsupported protocols return before credential dispatch") } } @@ -320,7 +431,33 @@ impl SourceService { ) .await } - SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + SourceKind::McpHttp => { + let credential = decode_protocol_input(credential)?; + self.registry + .mcp + .replace_http_credentials( + &self.catalog, + &source.id, + expected_revision, + credential, + audit, + ) + .await + } + SourceKind::McpStdio => { + let credential = decode_protocol_input(credential)?; + self.registry + .mcp + .replace_stdio_credentials( + &self.catalog, + &source.id, + expected_revision, + credential, + audit, + ) + .await + } + SourceKind::Graphql => { unreachable!("unsupported protocols return before credential dispatch") } } @@ -341,7 +478,13 @@ impl SourceService { .clear_credentials(&self.catalog, &source.id, expected_revision, audit) .await } - SourceKind::Graphql | SourceKind::McpHttp | SourceKind::McpStdio => { + SourceKind::McpHttp | SourceKind::McpStdio => { + self.registry + .mcp + .clear_credentials(&self.catalog, &source, expected_revision, audit) + .await + } + SourceKind::Graphql => { unreachable!("unsupported protocols return before credential dispatch") } } @@ -443,7 +586,7 @@ mod tests { use crate::protocols::openapi::CreateOpenApiSource; #[test] - fn registry_has_one_supported_protocol_and_explicit_extension_slots() { + fn registry_supports_openapi_and_mcp_with_graphql_as_an_extension_slot() { let registry = ProtocolRegistry::default(); assert_eq!( registry.registration(SourceKind::Openapi), @@ -455,11 +598,11 @@ mod tests { ); assert_eq!( registry.registration(SourceKind::McpHttp), - ProtocolRegistration::McpHttpUnsupported + ProtocolRegistration::McpHttp ); assert_eq!( registry.registration(SourceKind::McpStdio), - ProtocolRegistration::McpStdioUnsupported + ProtocolRegistration::McpStdio ); } diff --git a/tests/catalog.rs b/tests/catalog.rs index 26ddb0848..094aeacfa 100644 --- a/tests/catalog.rs +++ b/tests/catalog.rs @@ -9,8 +9,9 @@ use executor::{ AppConfig, DatabaseError, ExecutorApp, catalog::{ ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, - ListToolsFilter, ModeProvenance, NewRequestLog, RequestOutcome, RequestSurface, SourceKind, - StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, ToolMode, UpdateSource, + InitialCatalogSnapshot, ListToolsFilter, ModeProvenance, NewRequestLog, RequestOutcome, + RequestSurface, SourceHealth, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, + ToolBinding, ToolMode, UpdateSource, }, openapi::{OpenApiBinding, OpenApiSecurityAlternative}, }; @@ -474,6 +475,876 @@ async fn refresh_is_atomic_and_preserves_overrides_through_tombstones() { assert_eq!(restored.mode_override, Some(ToolMode::Disabled)); } +#[tokio::test] +async fn source_health_failures_preserve_the_last_good_catalog_and_sync_restores_health() { + let executor = TestExecutor::new().await; + let source = executor.source("health-state").await; + let healthy = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "last-good".to_owned(), + content: json!({ "version": 1 }), + }], + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + AuditContext::system(None), + ) + .await + .expect("initial catalog should sync"); + let before_failure = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_before_failure = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let tool_before_failure = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("last-good tool should list") + .items + .remove(0); + + let failed = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_discovery_failed", + before_failure.revision, + AuditContext::system(Some("health-failure")), + ) + .await + .expect("source health failure should persist"); + assert_eq!(failed.health_status, SourceHealth::Error); + assert_eq!( + failed.health_error_code.as_deref(), + Some("mcp_discovery_failed") + ); + assert_eq!(failed.revision, before_failure.revision + 1); + assert_eq!(failed.catalog_revision, before_failure.catalog_revision); + assert_eq!(failed.last_refreshed_at, before_failure.last_refreshed_at); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before_failure + 1 + ); + let retried_failure = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_discovery_failed", + before_failure.revision, + AuditContext::system(Some("health-failure-retry")), + ) + .await + .expect("an identical stale error retry should be idempotent"); + assert_eq!(retried_failure.revision, failed.revision); + assert_eq!(retried_failure.catalog_revision, failed.catalog_revision); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before_failure + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE request_id = 'health-failure-retry'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("retry audit count should read"), + 0 + ); + let tool_after_failure = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("last-good tool should remain") + .items + .remove(0); + assert_eq!(tool_after_failure.id, tool_before_failure.id); + assert_eq!(tool_after_failure.revision, tool_before_failure.revision); + assert!(tool_after_failure.present); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM source_artifacts WHERE source_id = ? AND stable_key = 'last-good'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("last-good artifact should remain"), + 1 + ); + let audit_metadata = sqlx::query_scalar::<_, String>( + "SELECT metadata_json FROM audit_events WHERE request_id = 'health-failure'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("health audit should exist"); + assert_eq!( + serde_json::from_str::(&audit_metadata).expect("audit metadata should be JSON"), + json!({ + "healthStatus": "error", + "errorCode": "mcp_discovery_failed", + "reasonCode": "mcp_discovery_failed", + "globalRevision": global_before_failure + 1, + }) + ); + + let restored = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: failed.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "last-good".to_owned(), + content: json!({ "version": 2 }), + }], + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + AuditContext::system(None), + ) + .await + .expect("successful sync should restore healthy state"); + assert_eq!(restored.source_id, source.id); + let source_after_restore = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(source_after_restore.health_status, SourceHealth::Healthy); + assert_eq!(source_after_restore.health_error_code, None); + assert_eq!( + source_after_restore.catalog_revision, + healthy.catalog_revision + 1 + ); + let tool_after_restore = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id), + limit: 10, + ..Default::default() + }) + .await + .expect("restored tool should list") + .items + .remove(0); + assert_eq!(tool_after_restore.id, tool_before_failure.id); +} + +#[tokio::test] +async fn deferred_source_creation_atomically_persists_an_unknown_empty_catalog() { + let executor = TestExecutor::new().await; + let (source, catalog) = executor + .app + .catalog() + .create_source_with_catalog_health( + CreateSource { + kind: SourceKind::McpStdio, + preferred_slug: "deferred-stdio".to_owned(), + display_name: "Deferred stdio".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({}), + }, + InitialCatalogSnapshot { + artifacts: Vec::new(), + tools: Vec::new(), + }, + Vec::new(), + SourceHealth::Unknown, + AuditContext::system(Some("deferred-source-create")), + ) + .await + .expect("deferred source and its empty catalog should commit atomically"); + assert_eq!(source.health_status, SourceHealth::Unknown); + assert_eq!(source.health_error_code, None); + assert_eq!(source.last_refreshed_at, None); + assert_eq!(source.tool_count, 0); + assert_eq!(source.tombstoned_tool_count, 0); + assert_eq!(catalog.active_tool_count, 0); + assert_eq!(catalog.tombstoned_tool_count, 0); + assert_eq!(catalog.source_revision, source.revision); + assert_eq!(catalog.catalog_revision, source.catalog_revision); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources WHERE id = ?") + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("created source should exist"), + 1 + ); + + let source_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(executor.app.pool()) + .await + .expect("source count should read"); + let error = executor + .app + .catalog() + .create_source_with_catalog_health( + CreateSource { + kind: SourceKind::McpStdio, + preferred_slug: "invalid-error-source".to_owned(), + display_name: "Invalid error source".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({}), + }, + InitialCatalogSnapshot { + artifacts: Vec::new(), + tools: Vec::new(), + }, + Vec::new(), + SourceHealth::Error, + AuditContext::system(None), + ) + .await + .expect_err("error health without a stable code must be rejected before insertion"); + assert!(matches!( + error, + CatalogError::Validation { + code: "invalid_initial_source_health", + .. + } + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(executor.app.pool()) + .await + .expect("source count should read"), + source_count + ); +} + +#[tokio::test] +async fn credential_required_health_is_fixed_bounded_and_revision_guarded() { + let executor = TestExecutor::new().await; + let source = executor + .source_with_kind("stdio-needs-credential", SourceKind::McpStdio) + .await; + let global_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let already_unknown = executor + .app + .catalog() + .mark_source_credential_required( + &source.id, + source.revision, + AuditContext::system(Some("credential-required-existing")), + ) + .await + .expect("an already unknown incomplete source should be an idempotent success"); + assert_eq!(already_unknown.revision, source.revision); + assert_eq!(already_unknown.health_status, SourceHealth::Unknown); + assert_eq!(already_unknown.health_error_code, None); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before + ); + + let errored = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_start_failed", + source.revision, + AuditContext::system(None), + ) + .await + .expect("source should enter error state"); + let marked = executor + .app + .catalog() + .mark_source_credential_required( + &source.id, + errored.revision, + AuditContext::system(Some("credential-required")), + ) + .await + .expect("credential requirement should restore unknown state without violating schema"); + assert_eq!(marked.health_status, SourceHealth::Unknown); + assert_eq!(marked.health_error_code, None); + assert_eq!(marked.revision, errored.revision + 1); + assert_eq!(marked.catalog_revision, source.catalog_revision); + let global_after_mark = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + assert_eq!(global_after_mark, global_before + 2); + let transition_metadata = sqlx::query_scalar::<_, String>( + "SELECT metadata_json FROM audit_events WHERE request_id = 'credential-required'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("credential-required audit should exist"); + assert_eq!( + serde_json::from_str::(&transition_metadata) + .expect("credential-required audit should be JSON"), + json!({ + "healthStatus": "unknown", + "errorCode": null, + "reasonCode": "credential_required", + "globalRevision": global_after_mark, + }) + ); + + let retried = executor + .app + .catalog() + .mark_source_credential_required( + &source.id, + errored.revision, + AuditContext::system(Some("credential-required-retry")), + ) + .await + .expect("an identical stale retry should be idempotent"); + assert_eq!(retried.revision, marked.revision); + assert_eq!(retried.catalog_revision, marked.catalog_revision); + assert_eq!(retried.health_status, marked.health_status); + assert_eq!(retried.health_error_code, marked.health_error_code); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_after_mark + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE request_id = 'credential-required-retry'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("retry audit count should read"), + 0 + ); + + let stale = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_discovery_failed", + source.revision, + AuditContext::system(None), + ) + .await + .expect_err("stale health transition should fail"); + assert!(matches!( + stale, + CatalogError::RevisionConflict { + scope: "source", + expected, + actual, + } if expected == source.revision && actual == marked.revision + )); + let after_stale = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after_stale.revision, marked.revision); + assert_eq!(after_stale.health_status, SourceHealth::Unknown); + assert_eq!(after_stale.health_error_code, None); +} + +#[tokio::test] +async fn source_health_error_codes_reject_unbounded_or_nonstable_values_without_mutation() { + let executor = TestExecutor::new().await; + let source = executor.source("invalid-health-codes").await; + let global_revision = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + for invalid in [ + "", + "UPSTREAM_FAILED", + "upstream-failed", + "upstream__failed", + "upstream_failed_", + "1_upstream_failed", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + let error = executor + .app + .catalog() + .mark_source_error( + &source.id, + invalid, + source.revision, + AuditContext::system(None), + ) + .await + .expect_err("invalid health code should be rejected"); + assert!(matches!( + error, + CatalogError::Validation { + code: "invalid_source_health_error_code", + .. + } + )); + } + let after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after.revision, source.revision); + assert_eq!(after.health_status, SourceHealth::Unknown); + assert_eq!(after.health_error_code, None); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_revision + ); +} + +#[tokio::test] +async fn credential_replacement_and_catalog_sync_commit_as_one_revision() { + let executor = TestExecutor::new().await; + let source = executor + .source_with_kind("atomic-credential-sync", SourceKind::Openapi) + .await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "old-secret" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("initial credential should persist"); + let basis = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: basis.revision, + expected_credential_revision: Some(credential.revision), + artifacts: Vec::new(), + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + vec![openapi_binding("alpha", "GET")], + AuditContext::system(None), + ) + .await + .expect("initial catalog should sync"); + let before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential_before = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let global_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let replacement = CredentialPayload { + schema_version: 1, + payload: json!({ "token": "new-secret" }), + }; + let (stored, sync, current) = executor + .app + .catalog() + .replace_credential_and_sync_catalog( + &source.id, + &replacement, + CatalogSnapshot { + expected_source_revision: before.revision, + expected_credential_revision: Some(credential_before.revision), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "discovery".to_owned(), + content: json!({ "version": 2 }), + }], + tools: vec![ + staged("alpha", "Updated Alpha", ToolMode::Ask), + staged("beta", "Beta", ToolMode::Enabled), + ], + }, + vec![ + openapi_binding("alpha", "POST"), + openapi_binding("beta", "GET"), + ], + AuditContext::system(Some("atomic-credential-sync")), + ) + .await + .expect("credential and discovered catalog should commit together"); + assert_eq!(stored.revision, credential_before.revision + 1); + assert_eq!(stored.credential.payload, replacement.payload); + assert_eq!(current.revision, before.revision + 1); + assert_eq!(current.catalog_revision, before.catalog_revision + 1); + assert_eq!(sync.source_revision, current.revision); + assert_eq!(sync.catalog_revision, current.catalog_revision); + assert_eq!(sync.global_revision, global_before + 1); + assert_eq!(current.health_status, SourceHealth::Healthy); + assert_eq!(current.health_error_code, None); + assert_eq!(sync.active_tool_count, 2); + assert_eq!( + executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist") + .credential + .payload, + replacement.payload + ); + let audit = sqlx::query_scalar::<_, String>( + "SELECT group_concat(metadata_json, ' ') FROM audit_events \ + WHERE request_id = 'atomic-credential-sync'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("atomic audit metadata should read"); + assert!(!audit.contains("new-secret")); +} + +#[tokio::test] +async fn atomic_credential_sync_rolls_back_credential_on_late_catalog_failure() { + let executor = TestExecutor::new().await; + let source = executor.source("atomic-rollback").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "retained" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("initial credential should persist"); + let basis = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .sync( + &source.id, + vec![staged("current", "Current", ToolMode::Enabled)], + ) + .await; + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 25000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('atomic-history-%05d', value), ?, printf('atomic-key-%05d', value), \ + printf('atomic_%05d', value), 'Retained', NULL, '{}', '{}', 'enabled', \ + 0, 0, 1, 1, 1, 1 FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("tombstone ceiling fixture should seed"); + let before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + assert!(before.revision > basis.revision); + let credential_before = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let global_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let error = executor + .app + .catalog() + .replace_credential_and_sync_catalog( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "must-roll-back" }), + }, + CatalogSnapshot { + expected_source_revision: before.revision, + expected_credential_revision: Some(credential_before.revision), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "must-not-commit".to_owned(), + content: json!({ "atomic": true }), + }], + tools: vec![staged("replacement", "Replacement", ToolMode::Enabled)], + }, + Vec::new(), + AuditContext::system(Some("atomic-rollback")), + ) + .await + .expect_err("late catalog failure must roll back the credential replacement"); + assert!(matches!( + error, + CatalogError::Validation { + code: "catalog_too_large", + .. + } + )); + let credential_after = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(credential_after.revision, credential_before.revision); + assert_eq!( + credential_after.credential.payload, + json!({ "token": "retained" }) + ); + let after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after.revision, before.revision); + assert_eq!(after.catalog_revision, before.catalog_revision); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM source_artifacts WHERE source_id = ? AND stable_key = 'must-not-commit'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE request_id = 'atomic-rollback'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("audit count should read"), + 0 + ); +} + +#[tokio::test] +async fn atomic_credential_mutations_are_cas_guarded_and_set_unknown_together() { + let executor = TestExecutor::new().await; + let source = executor.source("atomic-unknown").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "secrets": ["old"] }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("initial credential should persist"); + let healthy = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let cleared = CredentialPayload { + schema_version: 1, + payload: json!({ "secrets": [] }), + }; + let first_store = executor.app.catalog().clone(); + let second_store = executor.app.catalog().clone(); + let first = first_store.replace_credential_and_mark_unknown( + &source.id, + &cleared, + healthy.revision, + credential.revision, + AuditContext::system(Some("atomic-unknown-1")), + ); + let second = second_store.replace_credential_and_mark_unknown( + &source.id, + &cleared, + healthy.revision, + credential.revision, + AuditContext::system(Some("atomic-unknown-2")), + ); + let (first, second) = tokio::join!(first, second); + let outcomes = [first, second]; + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes.iter().filter(|outcome| outcome.is_err()).count(), + 1 + ); + let (stored, unknown) = outcomes + .into_iter() + .find_map(Result::ok) + .expect("one atomic mutation should win"); + assert_eq!(stored.revision, credential.revision + 1); + assert_eq!(stored.credential.payload, cleared.payload); + assert_eq!(unknown.revision, healthy.revision + 1); + assert_eq!(unknown.health_status, SourceHealth::Unknown); + assert_eq!(unknown.health_error_code, None); + let persisted = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(persisted.revision, stored.revision); + assert_eq!(persisted.credential.payload, cleared.payload); + + let deleted = executor + .app + .catalog() + .delete_credential_and_mark_unknown( + &source.id, + unknown.revision, + persisted.revision, + AuditContext::system(None), + ) + .await + .expect("credential deletion and unknown health should commit together"); + assert_eq!(deleted.revision, unknown.revision + 1); + assert_eq!(deleted.health_status, SourceHealth::Unknown); + assert_eq!(deleted.health_error_code, None); + assert!( + executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential read should succeed") + .is_none() + ); +} + #[tokio::test] async fn generic_sync_rejects_unbound_openapi_tools_without_changing_catalog_state() { let executor = TestExecutor::new().await; diff --git a/tests/gateway_sources.rs b/tests/gateway_sources.rs new file mode 100644 index 000000000..ad7c5102a --- /dev/null +++ b/tests/gateway_sources.rs @@ -0,0 +1,284 @@ +use std::collections::BTreeMap; + +use axum::{ + Router, + body::Body, + http::{Method, Request, StatusCode, header}, + response::Response, +}; +use executor::{ + AppConfig, ExecutorApp, + catalog::{AuditContext, CatalogSnapshot, CreateSource, SourceKind, StagedTool, ToolMode}, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const PASSWORD: &str = "correct horse battery staple"; + +struct AdminSession { + cookie: String, + csrf: String, +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Body, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot(request.body(body).expect("request should build")) + .await + .expect("router should answer") +} + +async fn send_json( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + let mut headers = headers.to_vec(); + headers.push((header::CONTENT_TYPE.as_str(), "application/json")); + send(router, method, uri, Body::from(body.to_string()), &headers).await +} + +async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn response_cookies(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn setup_admin(app: &ExecutorApp) -> AdminSession { + let response = send_json( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("fresh app should expose a setup token"), + "username": "admin", + "password": PASSWORD, + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send_json( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = response_cookies(&response); + let csrf = response_json(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + AdminSession { cookie, csrf } +} + +async fn create_gateway_token(app: &ExecutorApp, admin: &AdminSession) -> String { + let response = send_json( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Gateway source discovery" }), + &[ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response_json(response).await["token"] + .as_str() + .expect("token should be revealed once") + .to_owned() +} + +fn staged_tool(stable_key: &str, mode: ToolMode) -> StagedTool { + StagedTool { + stable_key: stable_key.to_owned(), + preferred_name: stable_key.to_owned(), + display_name: stable_key.to_owned(), + description: None, + input_schema: json!({ "type": "object" }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: mode, + } +} + +async fn create_source( + app: &ExecutorApp, + slug: &str, + display_name: &str, + description: Option<&str>, + modes: &[ToolMode], +) { + let source = app + .catalog() + .create_source( + CreateSource { + kind: SourceKind::Graphql, + preferred_slug: slug.to_owned(), + display_name: display_name.to_owned(), + description: description.map(str::to_owned), + configuration: Map::new(), + }, + AuditContext::system(None), + ) + .await + .expect("source should be created"); + app.catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: modes + .iter() + .enumerate() + .map(|(index, mode)| staged_tool(&format!("tool_{index}"), *mode)) + .collect(), + }, + AuditContext::system(None), + ) + .await + .expect("catalog should be synchronized"); +} + +#[tokio::test] +async fn gateway_sources_returns_only_the_exact_public_dto_for_callable_sources() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup_admin(&app).await; + let token = create_gateway_token(&app, &admin).await; + + create_source( + &app, + "alpha", + "Alpha API", + Some("Enabled and approval-gated operations"), + &[ToolMode::Enabled, ToolMode::Ask, ToolMode::Disabled], + ) + .await; + create_source( + &app, + "disabled_only", + "Disabled API", + Some("Must not be discoverable"), + &[ToolMode::Disabled, ToolMode::Disabled], + ) + .await; + create_source(&app, "zulu", "Zulu API", None, &[ToolMode::Ask]).await; + + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::GET, + "/api/v1/gateway/sources", + Body::empty(), + &[(header::AUTHORIZATION.as_str(), authorization.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response_json(response).await, + json!({ + "sources": [ + { + "slug": "alpha", + "displayName": "Alpha API", + "description": "Enabled and approval-gated operations", + "kind": "graphql", + "toolCount": 2, + }, + { + "slug": "zulu", + "displayName": "Zulu API", + "description": null, + "kind": "graphql", + "toolCount": 1, + }, + ], + }) + ); + + app.shutdown().await; +} + +#[tokio::test] +async fn gateway_sources_rejects_missing_authentication_before_catalog_work() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + app.pool().close().await; + + let response = send( + app.router(), + Method::GET, + "/api/v1/gateway/sources", + Body::empty(), + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let request_id = response + .headers() + .get("x-request-id") + .expect("authentication error should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + assert_eq!( + response_json(response).await, + json!({ + "error": { + "code": "unauthorized", + "message": "A valid Executor API token is required.", + "requestId": request_id, + }, + }) + ); +} diff --git a/tests/outbound.rs b/tests/outbound.rs index ab0e047aa..a5a6d2016 100644 --- a/tests/outbound.rs +++ b/tests/outbound.rs @@ -245,6 +245,93 @@ async fn response_size_cap_rejects_declared_oversize_before_returning_body() { server_task.await.expect("server task completes"); } +#[tokio::test] +async fn streaming_response_yields_before_connection_eof_and_keeps_byte_cap() { + let server = TcpListener::bind("127.0.0.1:0") + .await + .expect("server binds"); + let address = server.local_addr().expect("server has an address"); + let server_task = tokio::spawn(async move { + let (mut stream, _) = server.accept().await.expect("server accepts"); + let _request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nfirst") + .await + .expect("first chunk writes"); + stream.flush().await.expect("first chunk flushes"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + let policy = OutboundPolicy { + allow_private_networks: true, + max_response_bytes: 5, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let request = OutboundRequest::new( + Method::GET, + Url::parse(&format!("http://{address}/stream")).expect("request URL parses"), + ); + let mut response = client + .execute_streaming(request) + .await + .expect("stream headers arrive"); + let first = tokio::time::timeout(std::time::Duration::from_millis(500), response.next_chunk()) + .await + .expect("chunk arrives before EOF") + .expect("chunk is valid") + .expect("chunk exists"); + assert_eq!(first, b"first"); + server_task.abort(); +} + +#[tokio::test] +async fn long_lived_stream_uses_idle_timeout_instead_of_request_deadline() { + let server = TcpListener::bind("127.0.0.1:0") + .await + .expect("server binds"); + let address = server.local_addr().expect("server has an address"); + let server_task = tokio::spawn(async move { + let (mut stream, _) = server.accept().await.expect("server accepts"); + let _request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\nfirst") + .await + .expect("headers write"); + stream.flush().await.expect("headers flush"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + stream.write_all(b"again").await.expect("event writes"); + stream.flush().await.expect("event flushes"); + }); + let policy = OutboundPolicy { + allow_private_networks: true, + request_timeout: std::time::Duration::from_millis(50), + max_response_bytes: 5, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let request = OutboundRequest::new( + Method::GET, + Url::parse(&format!("http://{address}/events")).expect("request URL parses"), + ); + let mut response = client + .execute_long_lived_streaming(request, std::time::Duration::from_millis(500)) + .await + .expect("long-lived headers arrive"); + let first = response + .next_chunk() + .await + .expect("idle deadline permits delayed event") + .expect("event chunk exists"); + let second = response + .next_chunk() + .await + .expect("cumulative lifetime bytes do not exhaust the per-chunk cap") + .expect("second chunk exists"); + assert_eq!(first, b"first"); + assert_eq!(second, b"again"); + server_task.await.expect("server task completes"); +} + #[tokio::test] async fn trace_and_connect_are_rejected_before_network_work() { let policy = OutboundPolicy { diff --git a/web/src/lib/McpCredentialEditor.svelte b/web/src/lib/McpCredentialEditor.svelte new file mode 100644 index 000000000..4f4b483f0 --- /dev/null +++ b/web/src/lib/McpCredentialEditor.svelte @@ -0,0 +1,383 @@ + + + + +{#if notice !== null} +

+ {notice} +

+{/if} + +{#if open} +
+
+

Replace credentials

+

Existing values are hidden. Saving replaces the complete credential set.

+
+ + {#if loading} +

Loading credential metadata...

+ {:else if revision !== null && source.kind === "mcp_http"} +
+ HTTP authentication + + {#if httpDraft.type === "api_key_header"} + + {/if} + {#if httpDraft.type === "basic"} + + {/if} + {#if httpDraft.type !== "none"} + + {/if} +
+ {:else if revision !== null && source.kind === "mcp_stdio"} +
+ Template secrets + {#each stdioFields as field (field.key)} + + {/each} +
+ {/if} + + {#if error !== null} +
+ {/if} +
+ + +
+
+{/if} + + diff --git a/web/src/lib/McpCredentialEditor.test.ts b/web/src/lib/McpCredentialEditor.test.ts new file mode 100644 index 000000000..6e73a73fb --- /dev/null +++ b/web/src/lib/McpCredentialEditor.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { Schema } from "effect"; +import McpCredentialEditor from "./McpCredentialEditor.svelte"; +import type { Source } from "./api"; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function source(kind: "mcp_http" | "mcp_stdio"): Source { + return { + id: `${kind}-source`, + kind, + slug: kind, + displayName: kind === "mcp_http" ? "Remote MCP" : "Local MCP", + description: null, + configuration: + kind === "mcp_http" + ? { endpoint: "https://mcp.example.test/mcp", allowPrivateNetwork: false } + : { templateName: "github" }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 3, + tombstonedToolCount: 0, + }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("MCP credential editor", () => { + it("replaces HTTP API-key auth with the viewed CAS revision", async () => { + const bodies: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + if (init?.method !== "PUT") { + return Response.json({ revision: 4, configuredSchemes: [] }); + } + bodies.push(decodeJson(String(init.body))); + return Response.json({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "api_key_header" }], + }); + }), + ); + render(McpCredentialEditor, { source: source("mcp_http") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const method = await screen.findByLabelText("Method"); + await fireEvent.change(method, { target: { value: "api_key_header" } }); + await fireEvent.input(screen.getByLabelText("Header name"), { + target: { value: "X-Service-Key" }, + }); + await fireEvent.input(screen.getByLabelText("Header value"), { + target: { value: " exact key " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(bodies).toEqual([ + { + expectedRevision: 4, + credential: { + credential: { + type: "api_key_header", + name: "X-Service-Key", + value: " exact key ", + }, + }, + }, + ]); + expect(document.body.textContent).not.toContain("exact key"); + expect(document.activeElement).toBe( + document.getElementById("mcp-credential-status-mcp_http-source"), + ); + }); + + it("uses only template-approved stdio fields and preserves secret bytes", async () => { + const bodies: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + if (String(input).endsWith("/credentials") && init?.method !== "PUT") { + return Response.json({ + revision: 9, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + } + if (String(input) === "/api/v1/mcp/stdio/templates") { + return Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }); + } + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 10, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + }), + ); + render(McpCredentialEditor, { source: source("mcp_stdio") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const secret = await screen.findByLabelText("TOKEN"); + await fireEvent.input(secret, { target: { value: " exact token " } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(bodies).toEqual([ + { + expectedRevision: 9, + credential: { secretValues: { TOKEN: " exact token " } }, + }, + ]); + expect(document.body.textContent).not.toContain("exact token"); + }); + + it("clears secrets and focuses a CAS conflict for review", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ) + .mockResolvedValueOnce( + Response.json( + { + error: { + code: "revision_conflict", + message: "Credentials changed elsewhere.", + requestId: "request-conflict", + }, + }, + { status: 409 }, + ), + ); + vi.stubGlobal("fetch", fetcher); + render(McpCredentialEditor, { source: source("mcp_http") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "clear-after-conflict" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(screen.queryByLabelText("Bearer token")).toBeNull(); + expect(document.body.textContent).not.toContain("clear-after-conflict"); + expect(document.activeElement).toBe( + document.getElementById("mcp-credential-error-mcp_http-source"), + ); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + }); + + it("does not reuse stdio fields when a later template lookup is invalid", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + revision: 2, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }), + ) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ) + .mockResolvedValueOnce( + Response.json({ + revision: 3, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }), + ) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "different", secretFields: ["OTHER"] }] }), + ); + vi.stubGlobal("fetch", fetcher); + render(McpCredentialEditor, { source: source("mcp_stdio") }); + + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await screen.findByLabelText("TOKEN"); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(screen.queryByLabelText("TOKEN")).toBeNull(); + expect(screen.queryByLabelText("OTHER")).toBeNull(); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + }); +}); diff --git a/web/src/lib/McpHttpSourceForm.svelte b/web/src/lib/McpHttpSourceForm.svelte new file mode 100644 index 000000000..0d7930caa --- /dev/null +++ b/web/src/lib/McpHttpSourceForm.svelte @@ -0,0 +1,288 @@ + + +
+
+ MCP Streamable HTTP +

+ Connect a remote or local MCP server. Executor negotiates the protocol and imports its tool + catalog. +

+ + + + +

+ This permits loopback and private-network targets. Link-local and cloud metadata targets stay + blocked. +

+
+ Authentication + + {#if credentialDraft.type === "api_key_header"} + + {/if} + {#if credentialDraft.type === "basic"} + + {/if} + {#if credentialDraft.type !== "none"} + + {/if} +

Credentials are encrypted locally and never shown again.

+
+ {#if localOptInMissing} +

+ This looks like a local or private endpoint. Enable private network access to connect it. +

+ {/if} +
+ +
+
+
Sessions
+
Kept in memory and never displayed
+
+
+
Redirects
+
Disabled
+
+
+
Proxies
+
Ignored, Executor connects directly
+
+
+ + {#if error !== null} +
+ {/if} + +
+ + diff --git a/web/src/lib/McpHttpSourceForm.test.ts b/web/src/lib/McpHttpSourceForm.test.ts new file mode 100644 index 000000000..1497255c6 --- /dev/null +++ b/web/src/lib/McpHttpSourceForm.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { Effect, Schema } from "effect"; +import McpHttpSourceForm from "./McpHttpSourceForm.svelte"; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function sourceFixture() { + return { + id: "source-1", + kind: "mcp_http", + slug: "issues", + displayName: "Issue tracker", + description: null, + configuration: { endpoint: "https://mcp.example.test/mcp", allowPrivateNetwork: false }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("MCP HTTP source form", () => { + it("submits the stable source contract and clears the completed draft", async () => { + let submittedBody = ""; + const fetcher = vi.fn(async (_input, init) => { + submittedBody = String(init?.body); + return Response.json(sourceFixture(), { status: 201 }); + }); + vi.stubGlobal("fetch", fetcher); + const created = vi.fn(); + render(McpHttpSourceForm, { oncreated: created }); + + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://mcp.example.test/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Issue tracker" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(created).toHaveBeenCalledOnce()); + expect(decodeJson(submittedBody)).toEqual({ + kind: "mcp_http", + displayName: "Issue tracker", + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + }); + expect(screen.getByLabelText("Endpoint").value).toBe(""); + expect(screen.getByLabelText("Source name").value).toBe(""); + }); + + it("blocks an obvious private endpoint until the operator opts in", async () => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + render(McpHttpSourceForm, { oncreated: vi.fn() }); + + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "http://127.42.0.1:7331/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local tools" }, + }); + + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(screen.getByText(/Enable private network access/)).toBeDefined(); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("rejects a completion after unmount and aborts the request", async () => { + const response = deferred(); + const request = { signal: null as AbortSignal | null }; + vi.stubGlobal( + "fetch", + vi.fn((_input, init) => { + request.signal = init?.signal ?? null; + return response.promise; + }), + ); + const created = vi.fn(); + const mounted = render(McpHttpSourceForm, { oncreated: created }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://mcp.example.test/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Issue tracker" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + response.resolve(Response.json(sourceFixture(), { status: 201 })); + await response.promise; + await Promise.resolve(); + expect(created).not.toHaveBeenCalled(); + }); + + it("settles busy state and focuses the error after a network rejection", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Effect.runPromise(Effect.fail("offline"))), + ); + render(McpHttpSourceForm, { oncreated: vi.fn() }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://mcp.example.test/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Issue tracker" }, + }); + await fireEvent.change(screen.getByLabelText("Method"), { + target: { value: "bearer" }, + }); + const bearer = screen.getByLabelText("Bearer token"); + await fireEvent.input(bearer, { target: { value: "clear-after-attempt" } }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + await waitFor(() => expect(bearer.value).toBe("")); + expect(bearer.disabled).toBe(false); + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(document.activeElement).toBe(document.getElementById("mcp-http-error")); + }); +}); diff --git a/web/src/lib/McpStdioSourceForm.svelte b/web/src/lib/McpStdioSourceForm.svelte new file mode 100644 index 000000000..61d1eef0a --- /dev/null +++ b/web/src/lib/McpStdioSourceForm.svelte @@ -0,0 +1,302 @@ + + +
+
+

Trusted local MCP template

+

+ Choose a template configured on this machine. Browser users cannot enter commands, arguments, + working directories, or environment variable names. +

+
+ {#if templates.data !== null && templates.data.length > 0 && !templates.stale} + + {/if} + + {#if templates.stale && templates.error !== null} +
+ Showing the last loaded template list while Executor reconnects. + + +
+ {:else if templates.error !== null} +
+ + +
+ {/if} + + {#if templates.data === null && templates.loading} +

Loading trusted templates...

+ {:else if templates.data?.length === 0} +
+ No trusted local templates are configured. + Add templates through Executor's local CLI or configuration, then try again. +
+ + {:else if templates.data !== null} +
+
+ Local process source + + + + {#each currentFields as field (field.key)} + + {/each} +
+ + {#if error !== null} +
+ {/if} + +
+ {/if} +
+ + diff --git a/web/src/lib/McpStdioSourceForm.test.ts b/web/src/lib/McpStdioSourceForm.test.ts new file mode 100644 index 000000000..a1e885a8a --- /dev/null +++ b/web/src/lib/McpStdioSourceForm.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { Effect, Schema } from "effect"; +import McpStdioSourceForm from "./McpStdioSourceForm.svelte"; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function sourceFixture() { + return { + id: "source-stdio", + kind: "mcp_stdio", + slug: "github-local", + displayName: "Local GitHub", + description: null, + configuration: { templateName: "github" }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 3, + tombstonedToolCount: 0, + }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("MCP stdio source form", () => { + it("shows only trusted templates and preserves secret bytes in create payloads", async () => { + const bodies: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + if (String(input) === "/api/v1/mcp/stdio/templates") { + return Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }); + } + expect(String(input)).toBe("/api/v1/sources"); + expect(init?.method).toBe("POST"); + bodies.push(decodeJson(String(init?.body))); + return Response.json(sourceFixture(), { status: 201 }); + }), + ); + const created = vi.fn(); + render(McpStdioSourceForm, { oncreated: created }); + + await screen.findByRole("option", { name: "github" }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local GitHub" }, + }); + await fireEvent.input(screen.getByLabelText("TOKEN"), { + target: { value: " whitespace-sensitive " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(created).toHaveBeenCalledOnce()); + expect(fetch).toHaveBeenCalledTimes(2); + expect(bodies).toEqual([ + { + kind: "mcp_stdio", + displayName: "Local GitHub", + templateName: "github", + secretValues: { TOKEN: " whitespace-sensitive " }, + }, + ]); + expect(document.body.textContent).not.toContain("whitespace-sensitive"); + }); + + it("clears secrets whenever the trusted template changes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + templates: [ + { name: "github", secretFields: ["TOKEN"] }, + { name: "linear", secretFields: ["TOKEN"] }, + ], + }), + ), + ); + render(McpStdioSourceForm, { oncreated: vi.fn() }); + + const selector = await screen.findByLabelText("Trusted template"); + const secret = screen.getByLabelText("TOKEN"); + await fireEvent.input(secret, { target: { value: "must-not-cross-templates" } }); + await fireEvent.change(selector, { target: { value: "linear" } }); + + expect(screen.getByLabelText("TOKEN").value).toBe(""); + expect(document.body.textContent).not.toContain("must-not-cross-templates"); + }); + + it("shows an honest empty state instead of a dead connect action", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ templates: [] })), + ); + render(McpStdioSourceForm, { oncreated: vi.fn() }); + + expect(await screen.findByText("No trusted local templates are configured.")).toBeDefined(); + expect(screen.queryByRole("button", { name: "Connect source" })).toBeNull(); + expect(screen.getByRole("button", { name: "Refresh templates" })).toBeDefined(); + }); + + it("retries a failed template refresh before making stale fields writable", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ) + .mockImplementationOnce(() => Effect.runPromise(Effect.fail("offline"))) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ); + vi.stubGlobal("fetch", fetcher); + render(McpStdioSourceForm, { oncreated: vi.fn() }); + + await screen.findByRole("option", { name: "github" }); + await fireEvent.click(screen.getByRole("button", { name: "Refresh templates" })); + expect(await screen.findByText(/Showing the last loaded template list/)).toBeDefined(); + expect( + screen.getByRole("group", { name: "Local process source" }).disabled, + ).toBe(true); + + await fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await waitFor(() => + expect( + screen.getByRole("group", { name: "Local process source" }).disabled, + ).toBe(false), + ); + }); + + it("clears secrets when a same-name template changes its approved fields", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["API_KEY"] }] }), + ); + vi.stubGlobal("fetch", fetcher); + render(McpStdioSourceForm, { oncreated: vi.fn() }); + + const oldSecret = await screen.findByLabelText("TOKEN"); + await fireEvent.input(oldSecret, { target: { value: "must-be-forgotten" } }); + await fireEvent.click(screen.getByRole("button", { name: "Refresh templates" })); + + const nextSecret = await screen.findByLabelText("API_KEY"); + expect(nextSecret.value).toBe(""); + expect(screen.queryByLabelText("TOKEN")).toBeNull(); + expect(document.body.textContent).not.toContain("must-be-forgotten"); + }); + + it("clears entered secrets and restores controls after a failed create", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ) + .mockImplementationOnce(() => Effect.runPromise(Effect.fail("offline"))); + vi.stubGlobal("fetch", fetcher); + render(McpStdioSourceForm, { oncreated: vi.fn() }); + + await screen.findByRole("option", { name: "github" }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local GitHub" }, + }); + await fireEvent.input(screen.getByLabelText("TOKEN"), { + target: { value: "clear-me" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(screen.getByLabelText("TOKEN").value).toBe(""); + expect(document.activeElement).toBe(document.getElementById("mcp-stdio-error")); + }); +}); diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index f56521959..93b7a1041 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -3,14 +3,18 @@ import { Schema } from "effect"; import { ApiError, bulkSetToolModes, + createMcpHttpSource, + createMcpStdioSource, createOpenApiSource, createToken, decideApproval, deleteOpenApiCredentials, getApproval, getOpenApiCredentials, + getSourceCredentials, getBootstrap, listApprovals, + listMcpStdioTemplates, listRequestLogs, listSources, listTokens, @@ -18,6 +22,8 @@ import { loginAdmin, previewOpenApiSource, putOpenApiCredentials, + putMcpHttpCredentials, + putMcpStdioCredentials, refreshOpenApiSource, setSourceMode, } from "./api"; @@ -325,6 +331,37 @@ describe("dashboard API client", () => { }); }); + it("strips nested private source configuration before it enters browser state", async () => { + const secret = "nested-source-secret"; + const result = await listSources(async () => + Response.json({ + sources: [ + { + ...sourceFixture(), + kind: "mcp_http", + configuration: { + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + sessionId: secret, + query: `token=${secret}`, + headers: { authorization: secret }, + env: { TOKEN: secret }, + stderr: secret, + command: secret, + }, + }, + ], + catalogRevision: 12, + }), + ); + + expect(result.ok && result.value.sources[0]?.configuration).toEqual({ + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + }); + expect(JSON.stringify(result)).not.toContain(secret); + }); + it("sends source and current-page bulk revisions exactly once", async () => { const bodies: unknown[] = []; await setSourceMode("source/1", "ask", 7, async (input, init) => { @@ -469,7 +506,9 @@ describe("dashboard API client", () => { schemes: { bearerAuth: { type: "bearer", token: secret } }, }, }, - async (_input, init) => { + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + expect(init?.method).toBe("POST"); body = decodeJson(String(init?.body)); return Response.json({ ...sourceFixture(), credentials: secret }, { status: 201 }); }, @@ -480,6 +519,198 @@ describe("dashboard API client", () => { expect(JSON.stringify(result)).not.toContain(secret); }); + it("creates an MCP HTTP source and strips unrecognized connection secrets", async () => { + let body: unknown; + const result = await createMcpHttpSource( + { + kind: "mcp_http", + displayName: "Issue tracker", + description: "Local issue tools", + endpoint: "https://mcp.example.test/rpc?tenant=private", + allowPrivateNetwork: false, + }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + body = decodeJson(String(init?.body)); + return Response.json( + { + ...sourceFixture(), + kind: "mcp_http", + displayName: "Issue tracker", + configuration: { + endpoint: "https://mcp.example.test/rpc", + allowPrivateNetwork: false, + }, + sessionId: "upstream-session-secret", + authorization: "Bearer source-secret", + }, + { status: 201 }, + ); + }, + ); + + expect(body).toEqual({ + kind: "mcp_http", + displayName: "Issue tracker", + description: "Local issue tools", + endpoint: "https://mcp.example.test/rpc?tenant=private", + allowPrivateNetwork: false, + }); + expect(result.ok && result.value.kind).toBe("mcp_http"); + expect(JSON.stringify(result)).not.toContain("upstream-session-secret"); + expect(JSON.stringify(result)).not.toContain("source-secret"); + }); + + it("sends every supported initial MCP HTTP credential directly on source create", async () => { + const credentials = [ + { type: "bearer", token: "bearer-secret" }, + { type: "basic", username: "admin", password: "password-secret" }, + { type: "api_key_header", name: "X-Service-Key", value: "header-secret" }, + { type: "oauth_access_token", accessToken: "oauth-secret" }, + ] as const; + const bodies: unknown[] = []; + + for (const credential of credentials) { + await createMcpHttpSource( + { + kind: "mcp_http", + displayName: "Authenticated MCP", + endpoint: "https://mcp.example.test/mcp", + credential, + }, + async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + ...sourceFixture(), + kind: "mcp_http", + configuration: { + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + }, + }); + }, + ); + } + + expect(bodies).toEqual( + credentials.map((credential) => ({ + kind: "mcp_http", + displayName: "Authenticated MCP", + endpoint: "https://mcp.example.test/mcp", + credential, + })), + ); + }); + + it("lists trusted stdio templates without decoding raw process configuration", async () => { + const secret = "raw-process-secret"; + const result = await listMcpStdioTemplates(async (input) => { + expect(String(input)).toBe("/api/v1/mcp/stdio/templates"); + return Response.json({ + templates: [ + { + name: "github-local", + secretFields: ["GITHUB_TOKEN"], + command: "/usr/local/bin/private-server", + args: ["--token", secret], + env: { TOKEN: secret }, + }, + ], + }); + }); + + expect(result).toEqual({ + ok: true, + value: { templates: [{ name: "github-local", secretFields: ["GITHUB_TOKEN"] }] }, + }); + expect(JSON.stringify(result)).not.toContain(secret); + expect(JSON.stringify(result)).not.toContain("private-server"); + }); + + it("creates a trusted stdio source without accepting a secret echo", async () => { + const secret = " whitespace-sensitive-secret "; + let body: unknown; + const result = await createMcpStdioSource( + { + kind: "mcp_stdio", + displayName: "Local GitHub", + templateName: "github-local", + secretValues: { GITHUB_TOKEN: secret }, + }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + expect(init?.method).toBe("POST"); + body = decodeJson(String(init?.body)); + return Response.json({ + ...sourceFixture(), + kind: "mcp_stdio", + configuration: { templateName: "github-local" }, + secretValues: { GITHUB_TOKEN: secret }, + }); + }, + ); + + expect(body).toEqual({ + kind: "mcp_stdio", + displayName: "Local GitHub", + templateName: "github-local", + secretValues: { GITHUB_TOKEN: secret }, + }); + expect(result.ok && result.value.configuration).toEqual({ templateName: "github-local" }); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("reads MCP credential metadata and sends protocol-specific CAS replacements", async () => { + const reads = await getSourceCredentials("mcp/source", async (input) => { + expect(String(input)).toBe("/api/v1/sources/mcp%2Fsource/credentials"); + return Response.json({ + revision: 7, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + }); + const bodies: unknown[] = []; + await putMcpHttpCredentials( + "http-source", + 4, + { + credential: { type: "api_key_header", name: "X-Service-Key", value: "secret" }, + }, + async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "api_key_header" }], + }); + }, + ); + await putMcpStdioCredentials( + "stdio-source", + 7, + { secretValues: { TOKEN: " exact secret " } }, + async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 8, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + }, + ); + + expect(reads.ok && reads.value.revision).toBe(7); + expect(bodies).toEqual([ + { + expectedRevision: 4, + credential: { + credential: { type: "api_key_header", name: "X-Service-Key", value: "secret" }, + }, + }, + { + expectedRevision: 7, + credential: { secretValues: { TOKEN: " exact secret " } }, + }, + ]); + }); + it("decodes OpenAPI refresh counts", async () => { const result = await refreshOpenApiSource("source/1", async (input, init) => { expect(String(input)).toBe("/api/v1/sources/source%2F1/refresh"); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f792b3dd3..31d0d7cbc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -32,7 +32,12 @@ const TokenListSchema = Schema.Struct({ const ToolModeSchema = Schema.Literals(["enabled", "ask", "disabled"]); const ModeProvenanceSchema = Schema.Literals(["tool_override", "source_override", "intrinsic"]); -const JsonObjectSchema = Schema.Record(Schema.String, Schema.Unknown); +const SourcePublicConfigurationSchema = Schema.Struct({ + endpoint: Schema.optional(Schema.String), + allowPrivateNetwork: Schema.optional(Schema.Boolean), + templateName: Schema.optional(Schema.String), + negotiatedProtocolVersion: Schema.optional(Schema.String), +}); const SourceSchema = Schema.Struct({ id: Schema.String, @@ -40,7 +45,7 @@ const SourceSchema = Schema.Struct({ slug: Schema.String, displayName: Schema.String, description: Schema.NullOr(Schema.String), - configuration: JsonObjectSchema, + configuration: SourcePublicConfigurationSchema, modeOverride: Schema.NullOr(ToolModeSchema), healthStatus: Schema.Literals(["unknown", "healthy", "error"]), healthErrorCode: Schema.NullOr(Schema.String), @@ -110,7 +115,25 @@ const CredentialMetadataSchema = Schema.Struct({ configuredSchemes: Schema.Array( Schema.Struct({ name: Schema.String, - credentialType: Schema.Literals(["api_key", "bearer", "basic", "manual_oauth_access_token"]), + credentialType: Schema.Literals([ + "api_key", + "bearer", + "basic", + "manual_oauth_access_token", + "secret_env", + "header", + "api_key_header", + "oauth_access_token", + ]), + }), + ), +}); + +const McpStdioTemplateListSchema = Schema.Struct({ + templates: Schema.Array( + Schema.Struct({ + name: Schema.String, + secretFields: Schema.Array(Schema.String), }), ), }); @@ -264,6 +287,7 @@ export type SourceList = typeof SourceListSchema.Type; export type OpenApiPreview = typeof OpenApiPreviewSchema.Type; export type CatalogSyncResult = typeof CatalogSyncResultSchema.Type; export type OpenApiCredentialMetadata = typeof CredentialMetadataSchema.Type; +export type McpStdioTemplate = (typeof McpStdioTemplateListSchema.Type)["templates"][number]; export type ToolSummary = typeof ToolSummarySchema.Type; export type ToolRecord = typeof ToolRecordSchema.Type; export type ToolPage = typeof ToolPageSchema.Type; @@ -305,6 +329,9 @@ const decodeCatalogSyncResult = Schema.decodeUnknownOption( const decodeCredentialMetadata = Schema.decodeUnknownOption( Schema.fromJsonString(CredentialMetadataSchema), ); +const decodeMcpStdioTemplateList = Schema.decodeUnknownOption( + Schema.fromJsonString(McpStdioTemplateListSchema), +); const decodeToolRecord = Schema.decodeUnknownOption(Schema.fromJsonString(ToolRecordSchema)); const decodeToolPage = Schema.decodeUnknownOption(Schema.fromJsonString(ToolPageSchema)); const decodeBulkToolModeResult = Schema.decodeUnknownOption( @@ -457,6 +484,51 @@ export type OpenApiSourceInput = { }; }; +export type McpHttpSourceInput = { + readonly kind: "mcp_http"; + readonly displayName: string; + readonly description?: string; + readonly endpoint: string; + readonly allowPrivateNetwork?: boolean; + readonly credential?: Exclude; +}; + +export type McpStdioSourceInput = { + readonly kind: "mcp_stdio"; + readonly displayName: string; + readonly description?: string; + readonly templateName: string; + readonly secretValues: Readonly>; +}; + +export type McpHttpCredential = + | { readonly credential: null } + | { readonly credential: { readonly type: "bearer"; readonly token: string } } + | { + readonly credential: { + readonly type: "basic"; + readonly username: string; + readonly password: string; + }; + } + | { + readonly credential: { + readonly type: "api_key_header"; + readonly name: string; + readonly value: string; + }; + } + | { + readonly credential: { + readonly type: "oauth_access_token"; + readonly accessToken: string; + }; + }; + +export type McpStdioCredential = { + readonly secretValues: Readonly>; +}; + export async function previewOpenApiSource( spec: OpenApiSpecInput, allowPrivateNetwork: boolean, @@ -486,6 +558,40 @@ export async function createOpenApiSource( return decodeResponse(response.value, decodeSource); } +export async function createMcpHttpSource( + input: McpHttpSourceInput, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/sources", + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSource); +} + +export async function listMcpStdioTemplates(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/mcp/stdio/templates", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeMcpStdioTemplateList); +} + +export async function createMcpStdioSource( + input: McpStdioSourceInput, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/sources", + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSource); +} + export async function refreshOpenApiSource( sourceId: string, fetcher: Fetcher = fetch, @@ -500,6 +606,20 @@ export async function refreshOpenApiSource( return decodeResponse(response.value, decodeCatalogSyncResult); } +export async function refreshSourceCatalog( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/refresh`, + { method: "POST", body: "{}", signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCatalogSyncResult); +} + export async function getOpenApiCredentials( sourceId: string, fetcher: Fetcher = fetch, @@ -514,6 +634,40 @@ export async function getOpenApiCredentials( return decodeResponse(response.value, decodeCredentialMetadata); } +export async function getSourceCredentials( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function putMcpHttpCredentials( + sourceId: string, + expectedRevision: number, + credential: McpHttpCredential, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return putProtocolCredentials(sourceId, expectedRevision, credential, fetcher, signal); +} + +export async function putMcpStdioCredentials( + sourceId: string, + expectedRevision: number, + credential: McpStdioCredential, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return putProtocolCredentials(sourceId, expectedRevision, credential, fetcher, signal); +} + export async function putOpenApiCredentials( sourceId: string, expectedRevision: number, @@ -549,6 +703,26 @@ export async function deleteOpenApiCredentials( return decodeResponse(response.value, decodeCredentialMetadata); } +async function putProtocolCredentials( + sourceId: string, + expectedRevision: number, + credential: McpHttpCredential | McpStdioCredential, + fetcher: Fetcher, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { + method: "PUT", + body: JSON.stringify({ expectedRevision, credential }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + export type ToolListFilters = { query?: string; sourceId?: string; diff --git a/web/src/lib/mcp-source-state.test.ts b/web/src/lib/mcp-source-state.test.ts new file mode 100644 index 000000000..c2e5181e8 --- /dev/null +++ b/web/src/lib/mcp-source-state.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + MCP_TOKEN_PLACEHOLDER, + buildMcpHttpCredential, + downstreamMcpSnippet, + mcpHttpFingerprint, + redactedEndpointLabel, + reconcileTemplateSelection, + requiresPrivateNetworkOptIn, + safeMcpSourceDetails, + templateDescriptorFingerprint, + templateSecretFields, + validateTemplateCatalog, + validateTemplateDraft, + type McpTemplateField, +} from "./mcp-source-state"; + +const templateFields = [ + { + key: "access_token", + label: "Access token", + description: "Token issued by the local service.", + required: true, + secret: true, + }, + { + key: "workspace", + label: "Workspace", + description: "Optional workspace name.", + required: false, + secret: false, + }, +] as const satisfies readonly McpTemplateField[]; + +describe("MCP source state", () => { + it("requires explicit opt-in for obvious local and private endpoints", () => { + expect(requiresPrivateNetworkOptIn("http://localhost:7331/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://10.200.1.8/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://127.42.0.1/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("https://192.168.1.8/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("https://172.31.4.2/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://[fe9f::1]/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://[::ffff:10.200.1.8]/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("https://api.example.com/mcp")).toBe(false); + expect(requiresPrivateNetworkOptIn("not a URL")).toBe(false); + }); + + it("includes the private-network choice in preview identity", () => { + expect(mcpHttpFingerprint(" https://example.com/mcp ", false)).toBe( + "public:https://example.com/mcp", + ); + expect(mcpHttpFingerprint("https://example.com/mcp", true)).toBe( + "private:https://example.com/mcp", + ); + }); + + it("removes query credentials and fragments from endpoint labels", () => { + expect(redactedEndpointLabel("https://example.com/mcp?token=secret#debug")).toBe( + "https://example.com/mcp", + ); + expect(redactedEndpointLabel("file:///tmp/server")).toBeNull(); + }); + + it("accepts only template-approved fields and enforces required values", () => { + expect( + validateTemplateDraft(templateFields, { access_token: " secret ", workspace: "" }), + ).toEqual({ access_token: " secret " }); + expect(validateTemplateDraft(templateFields, { access_token: "" })).toBeNull(); + expect( + validateTemplateDraft(templateFields, { access_token: "secret", raw_command: "rm -rf" }), + ).toBeNull(); + }); + + it("builds supported HTTP auth without trimming secret bytes", () => { + expect( + buildMcpHttpCredential({ type: "none", headerName: "", username: "", secret: "" }), + ).toEqual({ + credential: null, + }); + expect( + buildMcpHttpCredential({ + type: "bearer", + headerName: "", + username: "", + secret: " exact token ", + }), + ).toEqual({ credential: { type: "bearer", token: " exact token " } }); + expect( + buildMcpHttpCredential({ + type: "api_key_header", + headerName: " X-Service-Key ", + username: "", + secret: " exact key ", + }), + ).toEqual({ + credential: { type: "api_key_header", name: "X-Service-Key", value: " exact key " }, + }); + expect( + buildMcpHttpCredential({ + type: "api_key_header", + headerName: "", + username: "", + secret: "key", + }), + ).toBeNull(); + expect( + buildMcpHttpCredential({ + type: "basic", + headerName: "", + username: " admin ", + secret: " exact password ", + }), + ).toEqual({ + credential: { type: "basic", username: "admin", password: " exact password " }, + }); + expect( + buildMcpHttpCredential({ + type: "oauth_access_token", + headerName: "", + username: "", + secret: " exact oauth token ", + }), + ).toEqual({ + credential: { type: "oauth_access_token", accessToken: " exact oauth token " }, + }); + }); + + it("rejects ambiguous template catalogs and preserves only valid selections", () => { + expect( + validateTemplateCatalog([ + { name: "github", secretFields: ["TOKEN"] }, + { name: "github", secretFields: ["OTHER"] }, + ]), + ).toBeNull(); + expect( + validateTemplateCatalog([{ name: "github", secretFields: ["TOKEN", "TOKEN"] }]), + ).toBeNull(); + const templates = [ + { name: "github", secretFields: ["TOKEN"] }, + { name: "linear", secretFields: ["API_KEY"] }, + ]; + expect(reconcileTemplateSelection("linear", templates)).toBe("linear"); + expect(reconcileTemplateSelection("missing", templates)).toBe("github"); + expect(reconcileTemplateSelection(null, [])).toBeNull(); + expect(templateSecretFields(templates[0]!)).toEqual([ + { + key: "TOKEN", + label: "TOKEN", + description: "Secret required by the github template.", + required: true, + secret: true, + }, + ]); + expect(templateDescriptorFingerprint(templates[0])).toBe('github:["TOKEN"]'); + expect(templateDescriptorFingerprint(null)).toBeNull(); + }); + + it("whitelists card metadata without returning sessions, commands, stderr, env, or secrets", () => { + const details = safeMcpSourceDetails("mcp_http", { + endpoint: "https://example.com/mcp?api_key=secret", + negotiatedProtocolVersion: "2025-06-18", + sessionId: "private-session", + command: "node", + stderr: "private output", + env: { TOKEN: "secret" }, + }); + + expect(details).toEqual({ + endpointLabel: "https://example.com/mcp", + templateName: null, + negotiatedProtocolVersion: "2025-06-18", + allowPrivateNetwork: false, + }); + expect(JSON.stringify(details)).not.toContain("secret"); + expect(JSON.stringify(details)).not.toContain("private-session"); + expect(JSON.stringify(details)).not.toContain("node"); + }); + + it("uses a placeholder token in downstream client configuration", () => { + const snippet = downstreamMcpSnippet("https://executor.example.test/admin"); + expect(snippet).toContain("https://executor.example.test/mcp"); + expect(snippet).toContain(MCP_TOKEN_PLACEHOLDER); + expect(snippet).not.toContain("sk_live"); + }); +}); diff --git a/web/src/lib/mcp-source-state.ts b/web/src/lib/mcp-source-state.ts new file mode 100644 index 000000000..e1938c274 --- /dev/null +++ b/web/src/lib/mcp-source-state.ts @@ -0,0 +1,201 @@ +export type McpSourceKind = "mcp_http" | "mcp_stdio"; + +export type McpTemplateField = { + readonly key: string; + readonly label: string; + readonly description: string; + readonly required: boolean; + readonly secret: boolean; +}; + +export type McpTemplateDraft = Readonly>; + +export type McpHttpAuthDraft = { + readonly type: "none" | "bearer" | "basic" | "api_key_header" | "oauth_access_token"; + readonly headerName: string; + readonly username: string; + readonly secret: string; +}; + +export type McpTemplateSummary = { + readonly name: string; + readonly secretFields: readonly string[]; +}; + +export type SafeMcpSourceDetails = { + readonly endpointLabel: string | null; + readonly templateName: string | null; + readonly negotiatedProtocolVersion: string | null; + readonly allowPrivateNetwork: boolean; +}; + +export const MCP_TOKEN_PLACEHOLDER = ""; + +export function mcpHttpFingerprint(endpoint: string, allowPrivateNetwork: boolean) { + return `${allowPrivateNetwork ? "private" : "public"}:${endpoint.trim()}`; +} + +export function requiresPrivateNetworkOptIn(endpoint: string) { + const url = parseHttpUrl(endpoint); + if (url === null) return false; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; + if (host === "::1" || isPrivateIpv6(host)) { + return true; + } + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false; + return isPrivateIpv4(octets); +} + +function isPrivateIpv4(octets: readonly number[]) { + if (octets[0] === 10 || octets[0] === 127) return true; + if (octets[0] === 169 && octets[1] === 254) return true; + if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true; + return octets[0] === 192 && octets[1] === 168; +} + +export function redactedEndpointLabel(endpoint: string) { + const url = parseHttpUrl(endpoint); + if (url === null) return null; + return `${url.origin}${url.pathname}`; +} + +export function validateTemplateDraft( + fields: readonly McpTemplateField[], + draft: McpTemplateDraft, +) { + const allowedKeys = new Set(fields.map((field) => field.key)); + const hasUnknownField = Object.keys(draft).some((key) => !allowedKeys.has(key)); + if (hasUnknownField) return null; + + const values: Record = {}; + for (const field of fields) { + const rawValue = draft[field.key] ?? ""; + if (field.required && rawValue.trim() === "") return null; + if (rawValue !== "") values[field.key] = field.secret ? rawValue : rawValue.trim(); + } + return values; +} + +export function buildMcpHttpCredential(draft: McpHttpAuthDraft) { + if (draft.type === "none") return { credential: null } as const; + if (draft.secret.trim() === "") return null; + if (draft.type === "bearer") { + return { credential: { type: "bearer", token: draft.secret } } as const; + } + if (draft.type === "basic") { + const username = draft.username.trim(); + if (username === "") return null; + return { credential: { type: "basic", username, password: draft.secret } } as const; + } + if (draft.type === "oauth_access_token") { + return { + credential: { type: "oauth_access_token", accessToken: draft.secret }, + } as const; + } + const name = draft.headerName.trim(); + if (name === "") return null; + return { credential: { type: "api_key_header", name, value: draft.secret } } as const; +} + +export function validateTemplateCatalog(templates: readonly McpTemplateSummary[]) { + const names = new Set(); + const validated: McpTemplateSummary[] = []; + for (const template of templates) { + if (template.name.trim() === "" || names.has(template.name)) return null; + names.add(template.name); + const fields = new Set(); + for (const field of template.secretFields) { + if (field.trim() === "" || fields.has(field)) return null; + fields.add(field); + } + validated.push({ name: template.name, secretFields: [...template.secretFields] }); + } + return validated; +} + +export function reconcileTemplateSelection( + selected: string | null, + templates: readonly McpTemplateSummary[], +) { + if (selected !== null && templates.some((template) => template.name === selected)) { + return selected; + } + return templates[0]?.name ?? null; +} + +export function templateDescriptorFingerprint(template: McpTemplateSummary | null | undefined) { + return template === null || template === undefined + ? null + : `${template.name}:${JSON.stringify(template.secretFields)}`; +} + +export function templateSecretFields(template: McpTemplateSummary): readonly McpTemplateField[] { + return template.secretFields.map((key) => ({ + key, + label: key, + description: `Secret required by the ${template.name} template.`, + required: true, + secret: true, + })); +} + +export function safeMcpSourceDetails( + kind: McpSourceKind, + configuration: Readonly>, +): SafeMcpSourceDetails { + return { + endpointLabel: + kind === "mcp_http" && typeof configuration.endpoint === "string" + ? redactedEndpointLabel(configuration.endpoint) + : null, + templateName: + kind === "mcp_stdio" && typeof configuration.templateName === "string" + ? configuration.templateName + : null, + negotiatedProtocolVersion: + typeof configuration.negotiatedProtocolVersion === "string" + ? configuration.negotiatedProtocolVersion + : null, + allowPrivateNetwork: configuration.allowPrivateNetwork === true, + }; +} + +export function downstreamMcpSnippet(origin: string) { + const endpoint = new URL("/mcp", origin).toString(); + return JSON.stringify( + { + mcpServers: { + executor: { + type: "http", + url: endpoint, + headers: { Authorization: `Bearer ${MCP_TOKEN_PLACEHOLDER}` }, + }, + }, + }, + null, + 2, + ); +} + +function parseHttpUrl(value: string) { + const normalized = value.trim(); + if (!URL.canParse(normalized)) return null; + const url = new URL(normalized); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; +} + +function isPrivateIpv6(host: string) { + if (host.startsWith("fc") || host.startsWith("fd")) return true; + if (host.startsWith("::ffff:")) { + const groups = host.split(":"); + const high = Number.parseInt(groups.at(-2) ?? "", 16); + const low = Number.parseInt(groups.at(-1) ?? "", 16); + if (Number.isInteger(high) && Number.isInteger(low)) { + return isPrivateIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]); + } + } + const firstGroup = Number.parseInt(host.split(":", 1)[0] ?? "", 16); + return Number.isInteger(firstGroup) && firstGroup >= 0xfe80 && firstGroup <= 0xfebf; +} diff --git a/web/src/routes/sources/+page.svelte b/web/src/routes/sources/+page.svelte index a8714d587..72d4dfec6 100644 --- a/web/src/routes/sources/+page.svelte +++ b/web/src/routes/sources/+page.svelte @@ -2,6 +2,9 @@ import { tick, untrack } from "svelte"; import DashboardShell from "$lib/DashboardShell.svelte"; import ErrorNotice from "$lib/ErrorNotice.svelte"; + import McpCredentialEditor from "$lib/McpCredentialEditor.svelte"; + import McpHttpSourceForm from "$lib/McpHttpSourceForm.svelte"; + import McpStdioSourceForm from "$lib/McpStdioSourceForm.svelte"; import { createOpenApiSource, deleteOpenApiCredentials, @@ -10,7 +13,7 @@ listSources, previewOpenApiSource, putOpenApiCredentials, - refreshOpenApiSource, + refreshSourceCatalog, setSourceMode, type ApiError, type OpenApiPreview, @@ -36,6 +39,7 @@ type CredentialDraft, type SupportedCredentialType, } from "$lib/openapi-credentials"; + import { safeMcpSourceDetails } from "$lib/mcp-source-state"; const auth = useAuthState(); const latest = createLatestRequest(); @@ -56,6 +60,7 @@ let displayNameEdited = $state(false); let preferredSlug = $state(""); let sourceDescription = $state(""); + let sourceType = $state<"openapi" | "mcp_http" | "mcp_stdio">("openapi"); let importCredentialRows = $state([]); let credentialEditorSource = $state(null); let confirmingCredentialClear = $state(null); @@ -183,6 +188,7 @@ ); if (!finishMutation("openapi-preview", controller)) return; previewBusy = false; + if (fingerprint !== currentSpecFingerprint()) return; if (!result.ok) { if (auth.recoverFromApiError(result.error)) return; importError = result.error; @@ -201,6 +207,7 @@ if (!previewCurrent || displayName.trim() === "") return; const credentials = buildCredentialMap(importCredentialRows); if (credentials === null) return; + const submissionFingerprint = currentImportFingerprint(); const controller = beginMutation("openapi-create"); createBusy = true; importError = null; @@ -225,7 +232,7 @@ return; } importNotice = `${result.value.displayName} was imported with ${result.value.toolCount} tools.`; - clearImportForm(); + if (submissionFingerprint === currentImportFingerprint()) clearImportForm(); refreshKey += 1; } @@ -233,7 +240,7 @@ const mutationId = sourceMutationId("refresh", sourceId); clearSourceMutationError(sourceId); const controller = beginMutation(mutationId); - const result = await refreshOpenApiSource(sourceId, undefined, controller.signal); + const result = await refreshSourceCatalog(sourceId, undefined, controller.signal); if (!finishMutation(mutationId, controller)) return; if (!result.ok) { if (auth.recoverFromApiError(result.error)) return; @@ -452,6 +459,10 @@ } function clearImportForm() { + cancelMutation("openapi-preview"); + cancelMutation("openapi-create"); + previewBusy = false; + createBusy = false; locatorType = "url"; specUrl = ""; specContent = ""; @@ -466,6 +477,28 @@ importError = null; } + function currentImportFingerprint() { + return JSON.stringify({ + spec: currentSpecFingerprint(), + displayName, + preferredSlug, + sourceDescription, + credentials: buildCredentialMap(importCredentialRows), + }); + } + + function switchSourceType(next: "openapi" | "mcp_http" | "mcp_stdio") { + if (next === sourceType) return; + if (sourceType === "openapi") clearImportForm(); + sourceType = next; + } + + function connectedMcpSource(source: SourceList["sources"][number]) { + importNotice = `${source.displayName} connected with ${source.toolCount} tools.`; + refreshKey += 1; + focusSourceStatus(); + } + function authenticatedPreviewWithoutCredentials() { if (preview === null || !preview.securitySchemes.some((scheme) => scheme.supported)) { return false; @@ -533,10 +566,16 @@ } function finishMutation(id: string, controller: AbortController) { - if (mutationControllers.get(id) !== controller || controller.signal.aborted) return false; + if (mutationControllers.get(id) !== controller) return false; + mutationControllers.delete(id); + pending = pending.filter((candidate) => candidate !== id); + return !controller.signal.aborted; + } + + function cancelMutation(id: string) { + mutationControllers.get(id)?.abort(); mutationControllers.delete(id); pending = pending.filter((candidate) => candidate !== id); - return true; } function formatTime(timestamp: number | null) { @@ -557,7 +596,7 @@ {#if conflictNotice !== null}
@@ -569,177 +608,219 @@
{/if}
- Connect an OpenAPI source -
-
- Specification location - - -
- {#if locatorType === "url"} - - {:else} - - {/if} -
- {#if preview.securitySchemes.length > 0} -
- Authentication schemes -

- Select every credential you want to configure. Tool requirements use OR between - groups and AND within a group. -

- {#each preview.securitySchemes as scheme (scheme.name)} - {@const row = importCredentialRows.find( - (candidate) => candidate.name === scheme.name, - )} -
- - {#if row?.enabled} - {#if row.credentialType === "basic"} +

+ Keep this off unless the specification or API intentionally runs on your local network. +

+ + + + + {#if importError !== null}{/if} + {#if preview !== null} +
{ + event.preventDefault(); + void createSource(); + }} + > +
+

Preview

+

{preview.title}

+

{preview.description ?? "No API description provided."}

+
+ {preview.toolCount} tools found + {#if !previewCurrent}

+ The specification changed. Preview it again before importing. +

{/if} +
+ {#each preview.tools.slice(0, 8) as tool, index (previewToolKey(tool.preferredName, index))} + {tool.displayName}{modeLabel(tool.intrinsicMode)} + {/each} + {#if preview.tools.length > 8}+ {preview.tools.length - 8} more{/if} +
+
+ + + + {#if preview.securitySchemes.length > 0} +
+ Authentication schemes +

+ Select every credential you want to configure. Tool requirements use OR between + groups and AND within a group. +

+ {#each preview.securitySchemes as scheme (scheme.name)} + {@const row = importCredentialRows.find( + (candidate) => candidate.name === scheme.name, + )} +
+ + {#if row?.enabled} + {#if row.credentialType === "basic"}{/if} + {/if} - - {/if} - {#if row?.enabled && row.credentialType === "oauth_access_token"} -

- Supply an access token manually. Executor does not run authorization flows or - refresh OAuth tokens yet. -

- {/if} -
- {/each} -
+ > + {/if} + {#if row?.enabled && row.credentialType === "oauth_access_token"} +

+ Supply an access token manually. Executor does not run authorization flows + or refresh OAuth tokens yet. +

+ {/if} +
+ {/each} +
+ {/if} +
+ + {#if authenticatedPreviewWithoutCredentials()} +

+ This specification declares authentication, but no credentials are selected. Protected + tools will fail until credentials are configured. +

{/if} - - - {#if authenticatedPreviewWithoutCredentials()} -

- This specification declares authentication, but no credentials are selected. Protected - tools will fail until credentials are configured. -

- {/if} -

Credentials are encrypted locally and are never shown again.

- +

Credentials are encrypted locally and are never shown again.

+ + {/if} + {:else if sourceType === "mcp_http"} + + {:else} + {/if} @@ -762,8 +843,8 @@ 01

No sources connected

- This instance has no source data yet. Use the OpenAPI importer above to connect the first - API. + This instance has no source data yet. Use the source connector above to add an API or MCP + server.

{:else if resource.data !== null} @@ -804,6 +885,44 @@

Refresh error: {source.healthErrorCode}

{/if} + {#if source.kind === "mcp_http" || source.kind === "mcp_stdio"} + {@const details = safeMcpSourceDetails(source.kind, source.configuration)} +
+
+
Transport
+
{source.kind === "mcp_http" ? "Streamable HTTP" : "Trusted local template"}
+
+ {#if details.endpointLabel !== null} +
+
Endpoint
+
{details.endpointLabel}
+
+ {/if} + {#if details.templateName !== null} +
+
Template
+
{details.templateName}
+
+ {/if} + {#if source.kind === "mcp_http"} +
+
Private network
+
{details.allowPrivateNetwork ? "Allowed" : "Blocked"}
+
+
+
Upstream sessions
+
Memory only
+
+ {/if} + {#if details.negotiatedProtocolVersion !== null} +
+
MCP version
+
{details.negotiatedProtocolVersion}
+
+ {/if} +
+ {/if} +
View tools - {#if source.kind === "openapi"} + {#if source.kind === "openapi" || source.kind === "mcp_http" || source.kind === "mcp_stdio"} + {/if} + {#if source.kind === "openapi"} + +{#if notice !== null} +

+ {notice} +

+{/if} + +{#if open} +
+
+

Replace credentials

+

Existing values are hidden. Saving replaces the complete credential set.

+
+ + {#if loading} +

Loading credential metadata...

+ {:else if revision !== null} +
+ GraphQL authentication + + {#if draft.type === "api_key_header"} + + {/if} + {#if draft.type === "basic"} + + {/if} + {#if draft.type !== "none"} + + {/if} +
+ {/if} + + {#if error !== null} +
+ {/if} +
+ + + {#if confirmingClear} + + + + + {:else} + + {/if} +
+
+{/if} + + diff --git a/web/src/lib/GraphqlCredentialEditor.test.ts b/web/src/lib/GraphqlCredentialEditor.test.ts new file mode 100644 index 000000000..9a5081a4b --- /dev/null +++ b/web/src/lib/GraphqlCredentialEditor.test.ts @@ -0,0 +1,339 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { ApiError, type ApiResult, type OpenApiCredentialMetadata, type Source } from "./api"; +import GraphqlCredentialEditor from "./GraphqlCredentialEditor.svelte"; +import type { GraphqlCredential } from "./graphql-source-state"; + +function sourceFixture(id = "graphql-source"): Source { + return { + id, + kind: "graphql", + slug: "product", + displayName: "Product API", + description: null, + configuration: { + endpoint: "https://api.example.test/graphql?token=hidden", + allowPrivateNetwork: false, + }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function metadata( + revision: number, + credentialType: "bearer" | "basic" | "api_key_header" | "oauth_access_token" = "bearer", +): OpenApiCredentialMetadata { + return { + revision, + configuredSchemes: [{ name: "default", credentialType }], + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(cleanup); + +describe("GraphQL credential editor", () => { + it("replaces credentials with the metadata CAS revision", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const saves: Array<{ revision: number; credential: GraphqlCredential }> = []; + const save = vi.fn( + async (_sourceId: string, revision: number, credential: GraphqlCredential) => { + saves.push({ revision, credential }); + return { ok: true, value: metadata(5, "api_key_header") } as const; + }, + ); + render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const method = await screen.findByLabelText("Method"); + await fireEvent.change(method, { target: { value: "api_key_header" } }); + await fireEvent.input(screen.getByLabelText("Header name"), { + target: { value: " X-Service-Key " }, + }); + await fireEvent.input(screen.getByLabelText("Header value"), { + target: { value: " exact key " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(saves).toEqual([ + { + revision: 4, + credential: { type: "api_key_header", name: "X-Service-Key", value: " exact key " }, + }, + ]); + expect(document.body.textContent).not.toContain("exact key"); + expect(document.body.textContent).not.toContain("token=hidden"); + expect(document.activeElement).toBe( + document.getElementById("graphql-credential-status-graphql-source"), + ); + }); + + it("requires explicit confirmation and clears credentials with CAS", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(7) }) as const); + const save = vi.fn(async () => ({ ok: true, value: metadata(8) }) as const); + render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await screen.findByLabelText("Method"); + await fireEvent.click(screen.getByRole("button", { name: "Clear credentials" })); + expect(document.activeElement).toBe( + document.getElementById("graphql-cancel-clear-graphql-source"), + ); + await fireEvent.click(screen.getByRole("button", { name: "Confirm clear" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(save).toHaveBeenCalledWith("graphql-source", 7, null, expect.any(AbortSignal)); + }); + + it("clears secret input and disables saving after a CAS conflict", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn( + async () => + ({ + ok: false, + error: new ApiError({ + code: "revision_conflict", + displayMessage: "Credentials changed elsewhere.", + requestId: "request-conflict", + status: 409, + }), + }) as const, + ); + render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const secret = await screen.findByLabelText("Bearer token"); + await fireEvent.input(secret, { target: { value: "clear-after-conflict" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + await waitFor(() => expect(screen.queryByLabelText("Bearer token")).toBeNull()); + expect(document.body.textContent).not.toContain("clear-after-conflict"); + expect(document.activeElement).toBe( + document.getElementById("graphql-credential-error-graphql-source"), + ); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + }); + + it("aborts metadata loading and ignores its completion after unmount", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const load = vi.fn((_sourceId: string, signal: AbortSignal) => { + request.signal = signal; + return response.promise; + }); + const save = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + mounted.unmount(); + + expect(request.signal?.aborted).toBe(true); + response.resolve({ ok: true, value: metadata(3) }); + await response.promise; + await Promise.resolve(); + expect(save).not.toHaveBeenCalled(); + }); + + it("aborts an old metadata load and rejects it after the source changes", async () => { + const first = deferred>(); + const second = deferred>(); + const firstRequest = { signal: null as AbortSignal | null }; + const load = vi.fn((sourceId: string, signal: AbortSignal) => { + if (sourceId === "source-a") { + firstRequest.signal = signal; + return first.promise; + } + return second.promise; + }); + const save = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + + await waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + expect(firstRequest.signal?.aborted).toBe(true); + second.resolve({ ok: true, value: metadata(8, "basic") }); + await screen.findByLabelText("Username"); + first.resolve({ ok: true, value: metadata(3, "bearer") }); + await first.promise; + await Promise.resolve(); + expect(screen.queryByLabelText("Bearer token")).toBeNull(); + }); + + it("reloads the CAS revision before saving after an open source changes", async () => { + const load = vi.fn(async (sourceId: string) => + sourceId === "source-a" + ? ({ ok: true, value: metadata(4) } as const) + : ({ ok: true, value: metadata(9, "basic") } as const), + ); + const save = vi.fn(async () => ({ ok: true, value: metadata(10, "basic") }) as const); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await screen.findByLabelText("Bearer token"); + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + await screen.findByLabelText("Username"); + await fireEvent.input(screen.getByLabelText("Username"), { target: { value: "operator" } }); + await fireEvent.input(screen.getByLabelText("Password"), { target: { value: "password" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(save).toHaveBeenCalledOnce()); + expect(save).toHaveBeenCalledWith( + "source-b", + 9, + { type: "basic", username: "operator", password: "password" }, + expect.any(AbortSignal), + ); + }); + + it("aborts a pending save and rejects its completion after the source changes", async () => { + const pendingSave = deferred>(); + const saveRequest = { signal: null as AbortSignal | null }; + const load = vi.fn(async (sourceId: string) => + sourceId === "source-a" + ? ({ ok: true, value: metadata(4) } as const) + : ({ ok: true, value: metadata(9, "basic") } as const), + ); + const save = vi.fn( + ( + _sourceId: string, + _revision: number, + _credential: GraphqlCredential, + signal: AbortSignal, + ) => { + saveRequest.signal = signal; + return pendingSave.promise; + }, + ); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "source-a-token" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + + await screen.findByLabelText("Username"); + expect(saveRequest.signal?.aborted).toBe(true); + pendingSave.resolve({ ok: true, value: metadata(5) }); + await pendingSave.promise; + await Promise.resolve(); + expect(screen.getByRole("button", { name: "Close credentials" })).toBeDefined(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByLabelText("Bearer token")).toBeNull(); + }); + + it("clears a completed source notice when the source identity changes", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn(async () => ({ ok: true, value: metadata(5) }) as const); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "source-a-token" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + expect(screen.getByRole("button", { name: "Manage credentials" })).toBeDefined(); + }); + + it("disables an open editor while a card operation is active", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture(), + load, + save, + disabled: false, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const method = await screen.findByLabelText("Method"); + await mounted.rerender({ source: sourceFixture(), load, save, disabled: true }); + + await waitFor(() => expect(method.closest("fieldset")?.hasAttribute("disabled")).toBe(true)); + expect( + screen.getByRole("button", { name: "Close credentials" }).disabled, + ).toBe(true); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + expect( + screen.getByRole("button", { name: "Clear credentials" }).disabled, + ).toBe(true); + expect(save).not.toHaveBeenCalled(); + }); + + it("reports save activity and aborts it when an external card operation starts", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn( + ( + _sourceId: string, + _revision: number, + _credential: GraphqlCredential, + signal: AbortSignal, + ) => { + request.signal = signal; + return response.promise; + }, + ); + const onbusychange = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture(), + load, + save, + onbusychange, + disabled: false, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "credential-secret" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(onbusychange).toHaveBeenLastCalledWith(true)); + + await mounted.rerender({ + source: sourceFixture(), + load, + save, + onbusychange, + disabled: true, + }); + await waitFor(() => expect(request.signal?.aborted).toBe(true)); + await waitFor(() => expect(onbusychange).toHaveBeenLastCalledWith(false)); + expect(document.body.textContent).not.toContain("credential-secret"); + response.resolve({ ok: true, value: metadata(5) }); + await response.promise; + }); +}); diff --git a/web/src/lib/GraphqlSourceForm.svelte b/web/src/lib/GraphqlSourceForm.svelte new file mode 100644 index 000000000..67a239bb4 --- /dev/null +++ b/web/src/lib/GraphqlSourceForm.svelte @@ -0,0 +1,315 @@ + + +
+
+ GraphQL API +

Connect an introspection-enabled GraphQL endpoint and import its queries and mutations.

+ + {#if endpointInvalid} +

+ Use HTTPS without user info, query parameters, or fragments. Plain HTTP is allowed only for + loopback development. Put secrets in Authentication. +

+ {/if} + + + + +

+ This permits loopback and private-network targets. Link-local and cloud metadata targets stay + blocked. +

+
+ Authentication + + {#if credentialDraft.type === "api_key_header"} + + {/if} + {#if credentialDraft.type === "basic"} + + {/if} + {#if credentialDraft.type !== "none"} + + {/if} + {#if credentialDraft.type === "oauth_access_token"} +

Paste an access token managed outside Executor.

+ {/if} +

Credentials are encrypted locally and never shown again.

+
+ {#if localOptInMissing} +

+ This looks like a local or private endpoint. Enable private network access to connect it. +

+ {/if} +
+ +
+
+
Introspection
+
Required when connecting and refreshing
+
+
+
Redirects
+
Disabled
+
+
+
Proxies
+
Ignored, Executor connects directly
+
+
+ + {#if error !== null} +
+ {/if} +

+ Queries start Enabled, mutations start Ask, and deprecated operations start Disabled. Review or + change any mode from Tools after connecting. +

+ +
+ + diff --git a/web/src/lib/GraphqlSourceForm.test.ts b/web/src/lib/GraphqlSourceForm.test.ts new file mode 100644 index 000000000..0acd254a9 --- /dev/null +++ b/web/src/lib/GraphqlSourceForm.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { ApiError, type ApiResult, type Source } from "./api"; +import GraphqlSourceForm from "./GraphqlSourceForm.svelte"; +import type { GraphqlSourceInput } from "./graphql-source-state"; + +function sourceFixture(): Source { + return { + id: "graphql-source", + kind: "graphql", + slug: "product", + displayName: "Product API", + description: null, + configuration: { endpoint: "https://api.example.test/graphql", allowPrivateNetwork: false }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +async function fillRequiredFields() { + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); +} + +afterEach(cleanup); + +describe("GraphQL source form", () => { + it("submits the stable source contract and clears the completed draft", async () => { + const inputs: GraphqlSourceInput[] = []; + const create = vi.fn(async (input: GraphqlSourceInput) => { + inputs.push(input); + return { ok: true, value: sourceFixture() } as const; + }); + const created = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: created }); + await fillRequiredFields(); + await fireEvent.input(screen.getByLabelText("Preferred slug (optional)"), { + target: { value: " product " }, + }); + await fireEvent.change(screen.getByLabelText("Method"), { target: { value: "bearer" } }); + await fireEvent.input(screen.getByLabelText("Bearer token"), { + target: { value: " exact token " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(created).toHaveBeenCalledOnce()); + expect(inputs).toEqual([ + { + kind: "graphql", + displayName: "Product API", + preferredSlug: "product", + endpoint: "https://api.example.test/graphql", + allowPrivateNetwork: false, + credential: { type: "bearer", token: " exact token " }, + }, + ]); + expect(screen.getByLabelText("Endpoint").value).toBe(""); + expect(document.body.textContent).not.toContain("exact token"); + }); + + it("rejects query-bearing endpoints before serialization", async () => { + const create = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql?token=do-not-store" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + + expect(screen.getByText(/without user info, query parameters, or fragments/)).toBeDefined(); + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(create).not.toHaveBeenCalled(); + }); + + it("rejects credential-bearing remote plaintext endpoints", async () => { + const create = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "http://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + await fireEvent.change(screen.getByLabelText("Method"), { target: { value: "bearer" } }); + await fireEvent.input(screen.getByLabelText("Bearer token"), { + target: { value: "do-not-send-in-cleartext" }, + }); + + expect(screen.getByText(/Plain HTTP is allowed only for loopback/)).toBeDefined(); + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(create).not.toHaveBeenCalled(); + }); + + it("blocks an obvious private endpoint until the operator opts in", async () => { + const create = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "http://127.0.0.1:4000/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local GraphQL" }, + }); + + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(screen.getByText(/Enable private network access/)).toBeDefined(); + expect(create).not.toHaveBeenCalled(); + }); + + it("aborts on unmount and rejects the late completion", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const create = vi.fn((_input: GraphqlSourceInput, signal: AbortSignal) => { + request.signal = signal; + return response.promise; + }); + const created = vi.fn(); + const mounted = render(GraphqlSourceForm, { create, oncreated: created }); + await fillRequiredFields(); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + mounted.unmount(); + + expect(request.signal?.aborted).toBe(true); + response.resolve({ ok: true, value: sourceFixture() }); + await response.promise; + await Promise.resolve(); + expect(created).not.toHaveBeenCalled(); + }); + + it("clears secrets and focuses a rejected request", async () => { + const create = vi.fn( + async () => + ({ + ok: false, + error: new ApiError({ + code: "graphql_introspection_failed", + displayMessage: "GraphQL introspection failed.", + requestId: "request-1", + status: 422, + }), + }) as const, + ); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fillRequiredFields(); + await fireEvent.change(screen.getByLabelText("Method"), { target: { value: "bearer" } }); + const secret = screen.getByLabelText("Bearer token"); + await fireEvent.input(secret, { target: { value: "clear-me" } }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(secret.value).toBe(""); + expect(document.body.textContent).not.toContain("clear-me"); + expect(document.activeElement).toBe(document.getElementById("graphql-source-error")); + }); +}); diff --git a/web/src/lib/McpCredentialEditor.svelte b/web/src/lib/McpCredentialEditor.svelte index 4f4b483f0..91e6dc25a 100644 --- a/web/src/lib/McpCredentialEditor.svelte +++ b/web/src/lib/McpCredentialEditor.svelte @@ -1,5 +1,5 @@ + +
+
+
+

Managed OAuth

+

{credentialKey}

+
+ {#if summary !== null} + + {oauthStatusLabel(summary.status)} + + {/if} +
+ + {#if loading} +

Loading OAuth configuration...

+ {:else} + {#if summary !== null} +
+
+
Issuer
+
{summary.issuer}
+
+
+
Client
+
{summary.clientAuthMethod === "none" ? "Public" : "Confidential"}
+
+
+
Granted scopes
+
{summary.grantedScopes.length > 0 ? summary.grantedScopes.join(" ") : "None yet"}
+
+
+
Refresh token
+
{summary.hasRefreshToken ? "Stored securely" : "Not stored"}
+
+
+ +
+ +
+ + +
+

Register this exact URL with the OAuth provider.

+

{copyStatus}

+
+ {/if} + +
+ {#if configurationUnavailable} +

+ This source no longer offers this managed OAuth credential. Disconnect or delete the saved + connection, or refresh the source configuration. +

+ {/if} +
+ {summary === null ? "Configure connection" : "Connection settings"} + + + + {#if draft.clientKind === "confidential"} + +
+ Client secret + {#if summary?.hasClientSecret} + + {/if} + + {#if draft.clientSecretAction === "replace"} + + {/if} +
+ {:else if summary?.hasClientSecret} +

+ Saving as a public client clears the stored client secret. +

+ {/if} + +
+ +
+ + +
+ {#if dirty && summary !== null} +

Save your changes before starting the OAuth authorization.

+ {/if} +
+ + {#if summary !== null} +
+ {#if confirmingDisconnect} + handleConfirmationKeydown(event, cancelDisconnectConfirmation)} + > + Disconnect OAuth tokens? +

The saved client configuration will remain available.

+
+ + +
+
+ {:else} + + {/if} + {#if confirmingDelete} + handleConfirmationKeydown(event, cancelDeleteConfirmation)} + > + Delete this OAuth configuration? +

This removes the client configuration and all stored OAuth tokens.

+
+ + +
+
+ {:else} + + {/if} +
+ {/if} + {/if} + + {#if notice !== null} +

{notice}

+ {/if} + {#if error !== null} + + {#if draftConflict && conflictLatestLoaded} + + {:else if draftConflict && conflictRefreshFailed} + + {:else if draftConflict} +

Loading the latest OAuth configuration...

+ {/if} + {/if} +
+ + diff --git a/web/src/lib/OAuthConnectionPanel.test.ts b/web/src/lib/OAuthConnectionPanel.test.ts new file mode 100644 index 000000000..e8a51153b --- /dev/null +++ b/web/src/lib/OAuthConnectionPanel.test.ts @@ -0,0 +1,521 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import OAuthConnectionPanel from "./OAuthConnectionPanel.svelte"; +import type { + OAuthConnectionOperations, + OAuthConnectionList, + OAuthConnectionSummary, + OAuthOperationResult, +} from "./oauth-connection-state"; + +function summary(overrides: Partial = {}): OAuthConnectionSummary { + return { + id: "oauth-1", + credentialKey: "provider-oauth", + revision: 4, + status: "connected", + issuer: "https://identity.example.test/", + clientId: "executor-client", + clientAuthMethod: "client_secret_basic", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/provider-oauth", + requestedScopes: ["read", "write"], + grantedScopes: ["read"], + hasClientSecret: true, + hasRefreshToken: true, + accessExpiresAt: 100, + authorizedAt: 90, + lastRefreshedAt: 95, + errorCode: null, + managedOAuthEligible: true, + ...overrides, + }; +} + +function success(value: Value): OAuthOperationResult { + return { ok: true, value }; +} + +function connectionList(connections: readonly OAuthConnectionSummary[]): OAuthConnectionList { + return { connections, availableCredentials: [] }; +} + +function operations(connection: OAuthConnectionSummary | null = summary()) { + return { + load: vi.fn(async (_sourceId: string, _signal: AbortSignal) => + success(connectionList(connection === null ? [] : [connection])), + ), + save: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => + success(summary({ revision: 5 })), + ), + authorize: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => + success({ authorizationUrl: "https://identity.example.test/authorize" }), + ), + disconnect: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => + success(summary({ revision: 5, status: "ready_to_connect", hasRefreshToken: false })), + ), + remove: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => success(undefined)), + } satisfies OAuthConnectionOperations; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(() => cleanup()); + +describe("managed OAuth connection panel", () => { + it("shows redacted connection metadata and keeps authorization separate from saving", async () => { + const api = operations(); + const navigate = vi.fn(); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + navigate, + }); + + expect(await screen.findByText("Connected")).toBeDefined(); + expect(screen.getByDisplayValue(summary().callbackUrl)).toBeDefined(); + expect(screen.getByText("Stored securely")).toBeDefined(); + expect(document.body.textContent).not.toContain("refresh-token"); + expect(document.body.textContent).not.toContain("client-secret"); + + await fireEvent.input(screen.getByLabelText(/Requested scopes/), { + target: { value: "read write profile" }, + }); + expect(screen.getByRole("button", { name: "Connect OAuth" }).disabled).toBe( + true, + ); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(api.save.mock.calls[0]?.[2]).toEqual({ + expectedRevision: 4, + discovery: { type: "issuer", issuer: "https://identity.example.test/" }, + client: { + clientId: "executor-client", + authentication: "client_secret_basic", + clientSecret: { action: "preserve" }, + }, + scopes: ["read", "write", "profile"], + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect OAuth" })); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith("https://identity.example.test/authorize"), + ); + expect(api.authorize).toHaveBeenCalledWith( + "source-1", + "provider-oauth", + { expectedRevision: 5 }, + expect.any(AbortSignal), + ); + }); + + it("clears a replacement secret after a failed save without echoing it", async () => { + const api = operations(); + const response = deferred>(); + api.save.mockImplementation(() => response.promise); + const failure = { + ok: false, + error: { + code: "upstream_rejected", + displayMessage: "The OAuth configuration was rejected.", + requestId: "request-safe", + status: 400, + }, + } as const; + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + + await screen.findByText("Connected"); + await fireEvent.click(screen.getByLabelText("Replace the saved secret")); + const secret = screen.getByLabelText("New client secret"); + await fireEvent.input(secret, { target: { value: " never-render-this " } }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(screen.getByLabelText("New client secret").value).toBe(""); + expect(document.body.textContent).not.toContain("never-render-this"); + response.resolve(failure); + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(api.save.mock.calls[0]?.[2].client).toEqual({ + clientId: "executor-client", + authentication: "client_secret_basic", + clientSecret: { action: "replace", value: " never-render-this " }, + }); + }); + + it("makes the public-client transition explicitly clear the stored secret", async () => { + const api = operations(); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + + await screen.findByText("Connected"); + await fireEvent.change(screen.getByLabelText("Client type"), { target: { value: "public" } }); + expect(screen.getByText(/clears the stored client secret/)).toBeDefined(); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(api.save.mock.calls[0]?.[2].client).toEqual({ + clientId: "executor-client", + authentication: "none", + }); + }); + + it("saves MCP discovery with default scopes and no required issuer override", async () => { + const api = operations(null); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "default", + defaultRequestedScopes: ["tools.read", "tools.call"], + discoveryType: "mcp", + operations: api, + }); + + expect(await screen.findByDisplayValue("tools.read tools.call")).toBeDefined(); + await fireEvent.input(screen.getByLabelText("Client ID"), { + target: { value: "executor-client" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(api.save.mock.calls[0]?.[2]).toEqual({ + expectedRevision: 0, + discovery: { type: "mcp" }, + client: { clientId: "executor-client", authentication: "none" }, + scopes: ["tools.read", "tools.call"], + }); + }); + + it("refetches on an opaque callback key and rejects an older completion", async () => { + const first = deferred>(); + const second = deferred>(); + const api = operations(); + api.load + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1:provider-secret-data", + operations: api, + }); + second.resolve(success(connectionList([summary({ status: "connected" })]))); + const status = await screen.findByText("Connected"); + expect(status.getAttribute("role")).toBe("status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + first.resolve(success(connectionList([summary({ status: "error" })]))); + await first.promise; + await Promise.resolve(); + expect(screen.queryByText("Connection error")).toBeNull(); + expect(document.body.textContent).not.toContain("provider-secret-data"); + }); + + it("aborts an owned load when the panel unmounts", async () => { + const request = { signal: null as AbortSignal | null }; + const pending = deferred>(); + const api = operations(); + api.load.mockImplementation((_sourceId, currentSignal) => { + request.signal = currentSignal; + return pending.promise; + }); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + + await waitFor(() => expect(request.signal).not.toBeNull()); + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + }); + + it("aborts and rejects an authorization completion after the panel identity changes", async () => { + const authorization = deferred>(); + const request = { signal: null as AbortSignal | null }; + const api = operations(); + api.authorize.mockImplementation((_sourceId, _credentialKey, _input, signal) => { + request.signal = signal; + return authorization.promise; + }); + const navigate = vi.fn(); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-a", + credentialKey: "provider-oauth", + operations: api, + navigate, + }); + await screen.findByText("Connected"); + await fireEvent.click(screen.getByRole("button", { name: "Connect OAuth" })); + await waitFor(() => expect(request.signal).not.toBeNull()); + + await mounted.rerender({ + sourceId: "source-b", + credentialKey: "provider-oauth", + operations: api, + navigate, + }); + expect(request.signal?.aborted).toBe(true); + authorization.resolve(success({ authorizationUrl: "https://old.example.test/authorize" })); + await authorization.promise; + await Promise.resolve(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("aborts a save when an OAuth callback triggers a status refresh", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const api = operations(); + api.save.mockImplementation((_sourceId, _credentialKey, _input, signal) => { + request.signal = signal; + return response.promise; + }); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + await screen.findByText("Connected"); + await fireEvent.input(screen.getByLabelText(/Requested scopes/), { + target: { value: "read profile" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(request.signal).not.toBeNull()); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1", + operations: api, + }); + expect(request.signal?.aborted).toBe(true); + response.resolve(success(summary({ revision: 99 }))); + await response.promise; + await Promise.resolve(); + expect(screen.queryByText(/OAuth configuration saved/)).toBeNull(); + }); + + it("aborts authorization when the operations implementation changes", async () => { + const authorization = deferred>(); + const request = { signal: null as AbortSignal | null }; + const firstOperations = operations(); + firstOperations.authorize.mockImplementation((_sourceId, _credentialKey, _input, signal) => { + request.signal = signal; + return authorization.promise; + }); + const nextOperations = operations(); + const navigate = vi.fn(); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: firstOperations, + navigate, + }); + await screen.findByText("Connected"); + await fireEvent.click(screen.getByRole("button", { name: "Connect OAuth" })); + await waitFor(() => expect(request.signal).not.toBeNull()); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: nextOperations, + navigate, + }); + expect(request.signal?.aborted).toBe(true); + authorization.resolve(success({ authorizationUrl: "https://old.example.test/authorize" })); + await authorization.promise; + await Promise.resolve(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("clears a completed mutation notice when the panel identity changes", async () => { + const api = operations(); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-a", + credentialKey: "provider-oauth", + operations: api, + }); + await screen.findByText("Connected"); + await fireEvent.input(screen.getByLabelText(/Requested scopes/), { + target: { value: "read profile" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + expect(await screen.findByText(/OAuth configuration saved/)).toBeDefined(); + + await mounted.rerender({ + sourceId: "source-b", + credentialKey: "provider-oauth", + operations: api, + }); + await waitFor(() => expect(screen.queryByText(/OAuth configuration saved/)).toBeNull()); + }); + + it("blocks a conflicted draft until the operator loads the latest revision", async () => { + const api = operations(); + api.load + .mockResolvedValueOnce(success(connectionList([summary({ revision: 4 })]))) + .mockResolvedValueOnce({ + ok: false, + error: { + code: "network_error", + displayMessage: "Executor could not load the latest configuration.", + requestId: null, + status: 503, + }, + }) + .mockResolvedValueOnce( + success(connectionList([summary({ revision: 5, requestedScopes: ["server-change"] })])), + ); + api.save + .mockResolvedValueOnce({ + ok: false, + error: { + code: "revision_conflict", + displayMessage: "OAuth configuration changed elsewhere.", + requestId: "request-conflict", + status: 409, + }, + }) + .mockResolvedValueOnce(success(summary({ revision: 6 }))); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + await screen.findByText("Connected"); + let scopes = screen.getByLabelText(/Requested scopes/); + await fireEvent.input(scopes, { target: { value: "my-draft" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + const retry = await screen.findByRole("button", { name: "Retry loading latest" }); + expect( + screen.getByRole("button", { name: "Save configuration" }).disabled, + ).toBe(true); + expect(api.save.mock.calls[0]?.[2].expectedRevision).toBe(4); + await fireEvent.click(retry); + const discard = await screen.findByRole("button", { name: "Discard draft and load latest" }); + await fireEvent.click(discard); + await waitFor(() => + expect(screen.getByLabelText(/Requested scopes/).value).toBe( + "server-change", + ), + ); + + scopes = screen.getByLabelText(/Requested scopes/); + await fireEvent.input(scopes, { target: { value: "server-change mine" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(api.save).toHaveBeenCalledTimes(2)); + expect(api.save.mock.calls[1]?.[2].expectedRevision).toBe(5); + }); + + it("allows access-token-only disconnects and closes confirmation when status clears", async () => { + const api = operations(); + api.load + .mockResolvedValueOnce( + success(connectionList([summary({ status: "connected", hasRefreshToken: false })])), + ) + .mockResolvedValueOnce( + success(connectionList([summary({ status: "ready_to_connect", hasRefreshToken: false })])), + ); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + expect(await screen.findByText("Not stored")).toBeDefined(); + const disconnect = screen.getByRole("button", { + name: "Disconnect tokens", + }); + expect(disconnect.disabled).toBe(false); + await fireEvent.click(disconnect); + expect(screen.getByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeDefined(); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1", + operations: api, + }); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeNull(), + ); + expect( + screen.getByRole("button", { name: "Disconnect tokens" }).disabled, + ).toBe(true); + }); + + it("confirms destructive actions and manages keyboard focus", async () => { + const api = operations(); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + await screen.findByText("Connected"); + + const disconnectOpener = screen.getByRole("button", { name: "Disconnect tokens" }); + await fireEvent.click(disconnectOpener); + const disconnectDialog = screen.getByRole("dialog", { name: "Disconnect OAuth tokens?" }); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Disconnect tokens" })); + await fireEvent.keyDown(disconnectDialog, { key: "Escape" }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Disconnect tokens" }), + ), + ); + + await fireEvent.click(screen.getByRole("button", { name: "Disconnect tokens" })); + await fireEvent.click(screen.getByRole("button", { name: "Disconnect tokens" })); + await waitFor(() => expect(api.disconnect).toHaveBeenCalledOnce()); + expect(api.disconnect).toHaveBeenCalledWith( + "source-1", + "provider-oauth", + { expectedRevision: 4 }, + expect.any(AbortSignal), + ); + const disconnectedNotice = screen.getByText(/OAuth tokens disconnected/); + await waitFor(() => expect(document.activeElement).toBe(disconnectedNotice)); + expect( + screen.getByRole("button", { name: "Disconnect tokens" }).disabled, + ).toBe(true); + + const deleteOpener = screen.getByRole("button", { name: "Delete configuration" }); + await fireEvent.click(deleteOpener); + expect(screen.getByText("Delete this OAuth configuration?")).toBeDefined(); + const deleteDialog = screen.getByRole("dialog", { + name: "Delete this OAuth configuration?", + }); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Delete" })); + await fireEvent.keyDown(deleteDialog, { key: "Escape" }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Delete configuration" }), + ), + ); + + await fireEvent.click(screen.getByRole("button", { name: "Delete configuration" })); + await fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(api.remove).toHaveBeenCalledOnce()); + expect(screen.queryByDisplayValue(summary().callbackUrl)).toBeNull(); + const deletedNotice = screen.getByText("OAuth configuration deleted."); + await waitFor(() => expect(document.activeElement).toBe(deletedNotice)); + }); +}); diff --git a/web/src/lib/OAuthSourceConnections.svelte b/web/src/lib/OAuthSourceConnections.svelte new file mode 100644 index 000000000..2e0daee17 --- /dev/null +++ b/web/src/lib/OAuthSourceConnections.svelte @@ -0,0 +1,253 @@ + + +{#if loading && list === null} +

Loading managed OAuth options...

+{:else if error !== null && list === null} + +{:else if entries.length > 0} +
+ {#if callbackNotice !== null} +

+ {callbackNotice.message} +

+ {/if} +
+
+

Managed OAuth

+

Provider connections

+
+

+ Executor stores refreshable tokens locally. Manual access tokens remain available under + advanced credential settings. +

+
+ {#each entries as entry (entry.credentialKey)} + setPanelBusy(entry.credentialKey, busy)} + /> + {/each} +
+{/if} + + diff --git a/web/src/lib/OAuthSourceConnections.test.ts b/web/src/lib/OAuthSourceConnections.test.ts new file mode 100644 index 000000000..ded584330 --- /dev/null +++ b/web/src/lib/OAuthSourceConnections.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import OAuthSourceConnections from "./OAuthSourceConnections.svelte"; +import type { + OAuthConnectionOperations, + OAuthConnectionSummary, + OAuthOperationResult, +} from "./oauth-connection-state"; +import type { Source } from "./api"; + +function source(): Source { + return { + id: "source-1", + kind: "openapi", + slug: "provider", + displayName: "Provider API", + description: null, + configuration: {}, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 1, + updatedAt: 1, + lastRefreshedAt: 1, + toolCount: 2, + tombstonedToolCount: 0, + }; +} + +function connection(overrides: Partial = {}): OAuthConnectionSummary { + return { + id: "connection-1", + credentialKey: "configured-oauth", + revision: 3, + status: "connected", + issuer: "https://identity.example.test/", + clientId: "executor", + clientAuthMethod: "none", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/connection-1", + requestedScopes: ["read"], + grantedScopes: ["read"], + hasClientSecret: false, + hasRefreshToken: true, + accessExpiresAt: null, + authorizedAt: 1, + lastRefreshedAt: 1, + errorCode: null, + managedOAuthEligible: true, + ...overrides, + }; +} + +function success(value: Value): OAuthOperationResult { + return { ok: true, value }; +} + +function operations() { + const list = { + connections: [connection()], + availableCredentials: [ + { + credentialKey: "new-oauth", + protocol: "openapi" as const, + requestedScopes: ["read", "write"], + managedOAuthEligible: true as const, + }, + ], + }; + return { + load: vi.fn(async () => success(list)), + save: vi.fn(async () => success(connection())), + authorize: vi.fn(async () => + success({ authorizationUrl: "https://identity.example.test/authorize?state=opaque" }), + ), + disconnect: vi.fn(async () => + success({ ...connection(), status: "ready_to_connect" as const }), + ), + remove: vi.fn(async () => success(undefined)), + } satisfies OAuthConnectionOperations; +} + +afterEach(cleanup); + +describe("OAuth source connections", () => { + it("renders only discovered and configured credential keys without a free-form key", async () => { + const api = operations(); + render(OAuthSourceConnections, { source: source(), operations: api }); + + expect(await screen.findByText("new-oauth")).toBeDefined(); + expect(screen.getByText("configured-oauth")).toBeDefined(); + expect(screen.queryByLabelText(/Credential key/i)).toBeNull(); + expect(screen.getByDisplayValue("read write")).toBeDefined(); + }); + + it("announces and consumes a failed callback only after its connection source refetches", async () => { + const api = operations(); + const checked = vi.fn(); + const busy = vi.fn(); + const mounted = render(OAuthSourceConnections, { + source: source(), + operations: api, + callbackRefreshKey: "failed:connection-1", + oncallbackchecked: checked, + onbusychange: busy, + }); + + await waitFor(() => expect(checked).toHaveBeenCalledWith(true)); + expect(screen.getByRole("alert").textContent).toContain("OAuth authorization did not complete"); + expect(busy).toHaveBeenCalledWith(true); + await waitFor(() => expect(busy).toHaveBeenLastCalledWith(false)); + + await mounted.rerender({ + source: source(), + operations: api, + callbackRefreshKey: "success:connection-1", + oncallbackchecked: checked, + onbusychange: busy, + }); + expect(await screen.findByText(/OAuth authorization completed/)).toBeDefined(); + expect(screen.queryByText(/OAuth authorization did not complete/)).toBeNull(); + }); + + it("cleans a failed callback for a missing connection without claiming a connection result", async () => { + const api = operations(); + api.load.mockResolvedValue( + success({ + connections: [], + availableCredentials: [ + { + credentialKey: "new-oauth", + protocol: "openapi", + requestedScopes: [], + managedOAuthEligible: true, + }, + ], + }), + ); + const checked = vi.fn(); + render(OAuthSourceConnections, { + source: source(), + operations: api, + callbackRefreshKey: "failed:deleted-connection", + oncallbackchecked: checked, + }); + + await waitFor(() => expect(checked).toHaveBeenCalledWith(false)); + expect(screen.queryByText(/OAuth authorization did not complete/)).toBeNull(); + }); + + it("keeps configured but ineligible connections removable while blocking save and connect", async () => { + const api = operations(); + api.load.mockResolvedValue( + success({ + connections: [connection({ managedOAuthEligible: false })], + availableCredentials: [], + }), + ); + render(OAuthSourceConnections, { source: source(), operations: api }); + + expect(await screen.findByText(/no longer offers this managed OAuth credential/)).toBeDefined(); + expect( + screen.getByRole("button", { name: "Save configuration" }).disabled, + ).toBe(true); + expect(screen.getByRole("button", { name: "Connect OAuth" }).disabled).toBe( + true, + ); + expect( + screen.getByRole("button", { name: "Disconnect tokens" }).disabled, + ).toBe(false); + expect( + screen.getByRole("button", { name: "Delete configuration" }).disabled, + ).toBe(false); + }); +}); diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 93b7a1041..d510b0495 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -2,18 +2,23 @@ import { describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; import { ApiError, + authorizeOAuthConnection, bulkSetToolModes, + createGraphqlSource, createMcpHttpSource, createMcpStdioSource, createOpenApiSource, createToken, decideApproval, + deleteOAuthConnection, deleteOpenApiCredentials, getApproval, getOpenApiCredentials, getSourceCredentials, getBootstrap, + disconnectOAuthConnection, listApprovals, + listOAuthConnections, listMcpStdioTemplates, listRequestLogs, listSources, @@ -24,6 +29,8 @@ import { putOpenApiCredentials, putMcpHttpCredentials, putMcpStdioCredentials, + putOAuthConnection, + putGraphqlCredentials, refreshOpenApiSource, setSourceMode, } from "./api"; @@ -356,7 +363,7 @@ describe("dashboard API client", () => { ); expect(result.ok && result.value.sources[0]?.configuration).toEqual({ - endpoint: "https://mcp.example.test/mcp", + endpoint: "https://mcp.example.test", allowPrivateNetwork: false, }); expect(JSON.stringify(result)).not.toContain(secret); @@ -602,6 +609,78 @@ describe("dashboard API client", () => { ); }); + it("creates GraphQL sources with the flattened credential contract and redacted public endpoint", async () => { + const secret = "graphql-query-secret"; + let body: unknown; + const result = await createGraphqlSource( + { + kind: "graphql", + displayName: "Product API", + preferredSlug: "product", + endpoint: `https://api.example.test/graphql?token=${secret}`, + allowPrivateNetwork: false, + credential: { type: "bearer", token: "bearer-secret" }, + }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + body = decodeJson(String(init?.body)); + return Response.json( + { + ...sourceFixture(), + kind: "graphql", + slug: "product", + displayName: "Product API", + configuration: { + endpoint: `https://api.example.test/graphql?token=${secret}`, + allowPrivateNetwork: false, + encryptedEndpoint: secret, + }, + }, + { status: 201 }, + ); + }, + ); + + expect(body).toEqual({ + kind: "graphql", + displayName: "Product API", + preferredSlug: "product", + endpoint: `https://api.example.test/graphql?token=${secret}`, + allowPrivateNetwork: false, + credential: { type: "bearer", token: "bearer-secret" }, + }); + expect(result.ok && result.value.configuration).toEqual({ + endpoint: "https://api.example.test", + allowPrivateNetwork: false, + }); + expect(JSON.stringify(result)).not.toContain(secret); + expect(JSON.stringify(result)).not.toContain("bearer-secret"); + }); + + it("strips path credentials from source endpoints before browser state", async () => { + const pathSecret = "path-secret-never-render"; + const result = await listSources(async () => + Response.json({ + sources: [ + { + ...sourceFixture(), + kind: "graphql", + configuration: { + endpoint: `https://api.example.test/graphql/${pathSecret}`, + allowPrivateNetwork: false, + }, + }, + ], + catalogRevision: 12, + }), + ); + + expect(result.ok && result.value.sources[0]?.configuration.endpoint).toBe( + "https://api.example.test", + ); + expect(JSON.stringify(result)).not.toContain(pathSecret); + }); + it("lists trusted stdio templates without decoding raw process configuration", async () => { const secret = "raw-process-secret"; const result = await listMcpStdioTemplates(async (input) => { @@ -711,6 +790,147 @@ describe("dashboard API client", () => { ]); }); + it("sends GraphQL replacement and clear credentials without an extra envelope", async () => { + const bodies: unknown[] = []; + await putGraphqlCredentials( + "graphql/source", + 7, + { type: "api_key_header", name: "X-Service-Key", value: " exact secret " }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/graphql%2Fsource/credentials"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 8, + configuredSchemes: [{ name: "default", credentialType: "api_key_header" }], + }); + }, + ); + await putGraphqlCredentials("graphql/source", 8, null, async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ revision: 9, configuredSchemes: [] }); + }); + + expect(bodies).toEqual([ + { + expectedRevision: 7, + credential: { + type: "api_key_header", + name: "X-Service-Key", + value: " exact secret ", + }, + }, + { expectedRevision: 8, credential: null }, + ]); + }); + + it("strictly decodes managed OAuth metadata and sends credential-keyed CAS operations", async () => { + const secret = "oauth-secret-never-decode"; + const connection = { + id: "connection-1", + credentialKey: "oauth/scheme", + revision: 4, + status: "connected", + issuer: "https://identity.example.test/", + clientId: "executor-client", + clientAuthMethod: "client_secret_basic", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/connection-1", + requestedScopes: ["read"], + grantedScopes: ["read"], + hasClientSecret: true, + hasRefreshToken: true, + accessExpiresAt: 900, + authorizedAt: 100, + lastRefreshedAt: 200, + errorCode: null, + managedOAuthEligible: true, + } as const; + const listed = await listOAuthConnections("source/1", async (input) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth"); + return Response.json({ + connections: [ + { + ...connection, + managedOAuthEligible: false, + accessToken: secret, + clientSecret: secret, + }, + ], + availableCredentials: [ + { + credentialKey: "oauth/scheme", + protocol: "openapi", + requestedScopes: ["read"], + managedOAuthEligible: true, + providerMetadata: secret, + }, + ], + tokenResponse: secret, + }); + }); + expect(listed.ok).toBe(true); + expect(listed.ok && listed.value.connections[0]?.managedOAuthEligible).toBe(false); + expect(JSON.stringify(listed)).not.toContain(secret); + + const bodies: unknown[] = []; + const saveInput = { + expectedRevision: 4, + discovery: { type: "issuer" as const, issuer: "https://identity.example.test/" }, + client: { + clientId: "executor-client", + authentication: "client_secret_basic" as const, + clientSecret: { action: "preserve" as const }, + }, + scopes: ["read"], + }; + await putOAuthConnection("source/1", "oauth/scheme", saveInput, async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth/oauth%2Fscheme"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ ...connection, revision: 5 }); + }); + await authorizeOAuthConnection( + "source/1", + "oauth/scheme", + { expectedRevision: 5 }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth/oauth%2Fscheme/authorize"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + authorizationUrl: "https://identity.example.test/authorize?state=opaque", + state: secret, + }); + }, + ); + const disconnected = await disconnectOAuthConnection( + "source/1", + "oauth/scheme", + { expectedRevision: 5 }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth/oauth%2Fscheme/disconnect"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + ...connection, + revision: 6, + status: "ready_to_connect", + managedOAuthEligible: false, + }); + }, + ); + expect(disconnected.ok && disconnected.value.managedOAuthEligible).toBe(false); + await deleteOAuthConnection( + "source/1", + "oauth/scheme", + { expectedRevision: 6 }, + async (input, init) => { + expect(String(input)).toBe( + "/api/v1/sources/source%2F1/oauth/oauth%2Fscheme?expectedRevision=6", + ); + expect(init?.method).toBe("DELETE"); + return new Response(null, { status: 204 }); + }, + ); + expect(bodies).toEqual([saveInput, { expectedRevision: 5 }, { expectedRevision: 5 }]); + }); + it("decodes OpenAPI refresh counts", async () => { const result = await refreshOpenApiSource("source/1", async (input, init) => { expect(String(input)).toBe("/api/v1/sources/source%2F1/refresh"); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 31d0d7cbc..2657510f0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,4 +1,20 @@ import { Option, Schema } from "effect"; +import type { GraphqlCredential, GraphqlSourceInput } from "$lib/graphql-source-state"; +import type { + OAuthAuthorization, + OAuthConnectionInput, + OAuthConnectionList, + OAuthConnectionSummary, +} from "$lib/oauth-connection-state"; + +export type { GraphqlCredential, GraphqlSourceInput } from "$lib/graphql-source-state"; +export type { + OAuthAuthorization, + OAuthAvailableCredential, + OAuthConnectionInput, + OAuthConnectionList, + OAuthConnectionSummary, +} from "$lib/oauth-connection-state"; const BootstrapSchema = Schema.Struct({ setupRequired: Schema.Boolean, @@ -138,6 +154,49 @@ const McpStdioTemplateListSchema = Schema.Struct({ ), }); +const OAuthConnectionStatusSchema = Schema.Literals([ + "not_configured", + "ready_to_connect", + "connecting", + "connected", + "reauthorization_required", + "error", +]); + +const OAuthConnectionSummarySchema = Schema.Struct({ + id: Schema.String, + credentialKey: Schema.String, + revision: Schema.Number, + status: OAuthConnectionStatusSchema, + issuer: Schema.String, + clientId: Schema.String, + clientAuthMethod: Schema.Literals(["none", "client_secret_basic", "client_secret_post"]), + callbackUrl: Schema.String, + requestedScopes: Schema.Array(Schema.String), + grantedScopes: Schema.Array(Schema.String), + hasClientSecret: Schema.Boolean, + hasRefreshToken: Schema.Boolean, + accessExpiresAt: Schema.NullOr(Schema.Number), + authorizedAt: Schema.NullOr(Schema.Number), + lastRefreshedAt: Schema.NullOr(Schema.Number), + errorCode: Schema.NullOr(Schema.String), + managedOAuthEligible: Schema.Boolean, +}); + +const OAuthAvailableCredentialSchema = Schema.Struct({ + credentialKey: Schema.String, + protocol: Schema.Literals(["openapi", "graphql", "mcp_http"]), + requestedScopes: Schema.Array(Schema.String), + managedOAuthEligible: Schema.Literal(true), +}); + +const OAuthConnectionListSchema = Schema.Struct({ + connections: Schema.Array(OAuthConnectionSummarySchema), + availableCredentials: Schema.Array(OAuthAvailableCredentialSchema), +}); + +const OAuthAuthorizationSchema = Schema.Struct({ authorizationUrl: Schema.String }); + const CatalogSyncResultSchema = Schema.Struct({ sourceId: Schema.String, sourceRevision: Schema.Number, @@ -318,8 +377,17 @@ const decodeBootstrap = Schema.decodeUnknownOption(Schema.fromJsonString(Bootstr const decodeSession = Schema.decodeUnknownOption(Schema.fromJsonString(SessionSchema)); const decodeCreatedToken = Schema.decodeUnknownOption(Schema.fromJsonString(CreatedTokenSchema)); const decodeTokenList = Schema.decodeUnknownOption(Schema.fromJsonString(TokenListSchema)); -const decodeSource = Schema.decodeUnknownOption(Schema.fromJsonString(SourceSchema)); -const decodeSourceList = Schema.decodeUnknownOption(Schema.fromJsonString(SourceListSchema)); +const decodeRawSource = Schema.decodeUnknownOption(Schema.fromJsonString(SourceSchema)); +const decodeRawSourceList = Schema.decodeUnknownOption(Schema.fromJsonString(SourceListSchema)); +const decodeSource = (text: string) => sanitizeDecodedSource(decodeRawSource(text)); +const decodeSourceList = (text: string) => { + const decoded = decodeRawSourceList(text); + if (Option.isNone(decoded)) return decoded; + return Option.some({ + ...decoded.value, + sources: decoded.value.sources.map(sanitizeSource), + }); +}; const decodeOpenApiPreview = Schema.decodeUnknownOption( Schema.fromJsonString(OpenApiPreviewSchema), ); @@ -332,6 +400,15 @@ const decodeCredentialMetadata = Schema.decodeUnknownOption( const decodeMcpStdioTemplateList = Schema.decodeUnknownOption( Schema.fromJsonString(McpStdioTemplateListSchema), ); +const decodeOAuthConnection = Schema.decodeUnknownOption( + Schema.fromJsonString(OAuthConnectionSummarySchema), +); +const decodeOAuthConnectionList = Schema.decodeUnknownOption( + Schema.fromJsonString(OAuthConnectionListSchema), +); +const decodeOAuthAuthorization = Schema.decodeUnknownOption( + Schema.fromJsonString(OAuthAuthorizationSchema), +); const decodeToolRecord = Schema.decodeUnknownOption(Schema.fromJsonString(ToolRecordSchema)); const decodeToolPage = Schema.decodeUnknownOption(Schema.fromJsonString(ToolPageSchema)); const decodeBulkToolModeResult = Schema.decodeUnknownOption( @@ -572,6 +649,20 @@ export async function createMcpHttpSource( return decodeResponse(response.value, decodeSource); } +export async function createGraphqlSource( + input: GraphqlSourceInput, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/sources", + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSource); +} + export async function listMcpStdioTemplates(fetcher: Fetcher = fetch, signal?: AbortSignal) { const response = await request("/api/v1/mcp/stdio/templates", { signal }, fetcher); if (!response.ok) return response; @@ -668,6 +759,104 @@ export async function putMcpStdioCredentials( return putProtocolCredentials(sourceId, expectedRevision, credential, fetcher, signal); } +export async function putGraphqlCredentials( + sourceId: string, + expectedRevision: number, + credential: GraphqlCredential, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { + method: "PUT", + body: JSON.stringify({ expectedRevision, credential }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function listOAuthConnections( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthConnectionList); +} + +export async function putOAuthConnection( + sourceId: string, + credentialKey: string, + input: OAuthConnectionInput, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}`, + { method: "PUT", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthConnection); +} + +export async function authorizeOAuthConnection( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}/authorize`, + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthAuthorization); +} + +export async function disconnectOAuthConnection( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}/disconnect`, + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthConnection); +} + +export async function deleteOAuthConnection( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}?expectedRevision=${encodeURIComponent(String(input.expectedRevision))}`, + { method: "DELETE", signal }, + fetcher, + ); + if (!response.ok) return response; + return { ok: true, value: undefined }; +} + export async function putOpenApiCredentials( sourceId: string, expectedRevision: number, @@ -993,3 +1182,33 @@ function readCookie(name: string) { } return null; } + +function sanitizeDecodedSource(decoded: Option.Option) { + return Option.isNone(decoded) ? decoded : Option.some(sanitizeSource(decoded.value)); +} + +function sanitizeSource(source: Source): Source { + const endpoint = source.configuration.endpoint; + return { + ...source, + configuration: { + ...(endpoint === undefined ? {} : publicEndpoint(endpoint)), + ...(source.configuration.allowPrivateNetwork === undefined + ? {} + : { allowPrivateNetwork: source.configuration.allowPrivateNetwork }), + ...(source.configuration.templateName === undefined + ? {} + : { templateName: source.configuration.templateName }), + ...(source.configuration.negotiatedProtocolVersion === undefined + ? {} + : { negotiatedProtocolVersion: source.configuration.negotiatedProtocolVersion }), + }, + }; +} + +function publicEndpoint(endpoint: string) { + if (!URL.canParse(endpoint)) return {}; + const url = new URL(endpoint); + if (url.protocol !== "http:" && url.protocol !== "https:") return {}; + return { endpoint: url.origin }; +} diff --git a/web/src/lib/graphql-source-state.test.ts b/web/src/lib/graphql-source-state.test.ts new file mode 100644 index 000000000..422148313 --- /dev/null +++ b/web/src/lib/graphql-source-state.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + buildGraphqlCredential, + graphqlDraftFromCredentialType, + normalizeGraphqlEndpoint, + requiresGraphqlPrivateNetworkOptIn, + safeGraphqlSourceDetails, +} from "./graphql-source-state"; + +describe("GraphQL source state", () => { + it("preserves secret bytes while trimming public credential fields", () => { + expect( + buildGraphqlCredential({ + type: "api_key_header", + headerName: " X-Service-Key ", + username: "", + secret: " exact secret ", + }), + ).toEqual({ type: "api_key_header", name: "X-Service-Key", value: " exact secret " }); + expect( + buildGraphqlCredential({ + type: "basic", + headerName: "", + username: " operator ", + secret: " exact password ", + }), + ).toEqual({ type: "basic", username: "operator", password: " exact password " }); + }); + + it("requires complete credentials and normalizes stored OAuth metadata", () => { + expect( + buildGraphqlCredential({ + type: "bearer", + headerName: "", + username: "", + secret: " ", + }), + ).toBeUndefined(); + expect(graphqlDraftFromCredentialType("manual_oauth_access_token").type).toBe( + "oauth_access_token", + ); + }); + + it("detects obvious private endpoints", () => { + expect(requiresGraphqlPrivateNetworkOptIn("http://127.10.0.1/graphql")).toBe(true); + expect(requiresGraphqlPrivateNetworkOptIn("http://192.168.2.4/graphql")).toBe(true); + expect(requiresGraphqlPrivateNetworkOptIn("http://[::ffff:7f00:1]/graphql")).toBe(true); + expect(requiresGraphqlPrivateNetworkOptIn("https://api.example.test/graphql")).toBe(false); + }); + + it("never exposes endpoint credentials, query parameters, or fragments", () => { + expect( + safeGraphqlSourceDetails({ + endpoint: "https://user:pass@api.example.test/graphql?token=secret#private", + allowPrivateNetwork: false, + }), + ).toEqual({ endpoint: "https://api.example.test", allowPrivateNetwork: false }); + expect( + safeGraphqlSourceDetails({ + endpoint: "https://api.example.test/graphql/path-secret", + allowPrivateNetwork: false, + }), + ).toEqual({ endpoint: "https://api.example.test", allowPrivateNetwork: false }); + expect(normalizeGraphqlEndpoint("https://api.example.test/graphql")).toBe( + "https://api.example.test/graphql", + ); + expect(normalizeGraphqlEndpoint("https://api.example.test/graphql?token=secret")).toBeNull(); + expect(normalizeGraphqlEndpoint("https://user:pass@api.example.test/graphql")).toBeNull(); + expect(normalizeGraphqlEndpoint("https://api.example.test/graphql#secret")).toBeNull(); + expect(normalizeGraphqlEndpoint("http://api.example.test/graphql")).toBeNull(); + expect(normalizeGraphqlEndpoint("http://127.0.0.1:4000/graphql")).toBe( + "http://127.0.0.1:4000/graphql", + ); + }); +}); diff --git a/web/src/lib/graphql-source-state.ts b/web/src/lib/graphql-source-state.ts new file mode 100644 index 000000000..915d96316 --- /dev/null +++ b/web/src/lib/graphql-source-state.ts @@ -0,0 +1,137 @@ +export type GraphqlAuthDraft = { + readonly type: "none" | "bearer" | "basic" | "api_key_header" | "oauth_access_token"; + readonly headerName: string; + readonly username: string; + readonly secret: string; +}; + +export type GraphqlCredential = + | null + | { readonly type: "bearer"; readonly token: string } + | { readonly type: "basic"; readonly username: string; readonly password: string } + | { readonly type: "api_key_header"; readonly name: string; readonly value: string } + | { readonly type: "oauth_access_token"; readonly accessToken: string }; + +export type GraphqlSourceInput = { + readonly kind: "graphql"; + readonly displayName: string; + readonly preferredSlug?: string; + readonly description?: string; + readonly endpoint: string; + readonly allowPrivateNetwork?: boolean; + readonly credential?: Exclude; +}; + +export function emptyGraphqlAuthDraft(): GraphqlAuthDraft { + return { type: "none", headerName: "", username: "", secret: "" }; +} + +export function clearGraphqlSecret(draft: GraphqlAuthDraft): GraphqlAuthDraft { + return { ...draft, headerName: "", username: "", secret: "" }; +} + +export function buildGraphqlCredential(draft: GraphqlAuthDraft): GraphqlCredential | undefined { + if (draft.type === "none") return null; + if (draft.secret.trim() === "") return undefined; + if (draft.type === "bearer") return { type: "bearer", token: draft.secret }; + if (draft.type === "basic") { + const username = draft.username.trim(); + return username === "" ? undefined : { type: "basic", username, password: draft.secret }; + } + if (draft.type === "oauth_access_token") { + return { type: "oauth_access_token", accessToken: draft.secret }; + } + const name = draft.headerName.trim(); + return name === "" ? undefined : { type: "api_key_header", name, value: draft.secret }; +} + +export function graphqlDraftFromCredentialType(type: string | undefined): GraphqlAuthDraft { + const normalized = type === "manual_oauth_access_token" ? "oauth_access_token" : type; + if ( + normalized === "bearer" || + normalized === "basic" || + normalized === "api_key_header" || + normalized === "oauth_access_token" + ) { + return { type: normalized, headerName: "", username: "", secret: "" }; + } + return emptyGraphqlAuthDraft(); +} + +export function requiresGraphqlPrivateNetworkOptIn(endpoint: string) { + const url = parseHttpUrl(endpoint); + if (url === null) return false; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; + if (host === "::1" || host.startsWith("fc") || host.startsWith("fd")) return true; + if (host.startsWith("::ffff:")) { + const groups = host.split(":"); + const high = Number.parseInt(groups.at(-2) ?? "", 16); + const low = Number.parseInt(groups.at(-1) ?? "", 16); + if (Number.isInteger(high) && Number.isInteger(low)) { + return isPrivateIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]); + } + } + const firstIpv6Group = Number.parseInt(host.split(":", 1)[0] ?? "", 16); + if (Number.isInteger(firstIpv6Group) && firstIpv6Group >= 0xfe80 && firstIpv6Group <= 0xfebf) { + return true; + } + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false; + return isPrivateIpv4(octets); +} + +export function normalizeGraphqlEndpoint(endpoint: string) { + const normalized = endpoint.trim(); + const url = parseHttpUrl(normalized); + if ( + url === null || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" || + (url.protocol === "http:" && !isLoopbackHost(url.hostname)) + ) { + return null; + } + return normalized; +} + +function isLoopbackHost(hostname: string) { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host.endsWith(".localhost") || host === "::1") return true; + const octets = host.split(".").map(Number); + return ( + octets.length === 4 && + octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) && + octets[0] === 127 + ); +} + +function isPrivateIpv4(octets: readonly number[]) { + if (octets[0] === 10 || octets[0] === 127) return true; + if (octets[0] === 169 && octets[1] === 254) return true; + if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true; + return octets[0] === 192 && octets[1] === 168; +} + +export function safeGraphqlSourceDetails(configuration: Readonly>) { + const endpoint = + typeof configuration.endpoint === "string" ? redactedEndpoint(configuration.endpoint) : null; + return { + endpoint, + allowPrivateNetwork: configuration.allowPrivateNetwork === true, + }; +} + +function redactedEndpoint(endpoint: string) { + const url = parseHttpUrl(endpoint); + return url === null ? null : url.origin; +} + +function parseHttpUrl(value: string) { + const normalized = value.trim(); + if (!URL.canParse(normalized)) return null; + const url = new URL(normalized); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; +} diff --git a/web/src/lib/oauth-connection-state.test.ts b/web/src/lib/oauth-connection-state.test.ts new file mode 100644 index 000000000..72015e12e --- /dev/null +++ b/web/src/lib/oauth-connection-state.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + buildOAuthConnectionInput, + canDisconnectOAuth, + draftFromOAuthSummary, + normalizeOAuthScopes, + oauthCallbackConnectionId, + oauthCallbackOutcomeNotice, + oauthCallbackNoticeWithoutEligibleSources, + oauthCallbackRefreshKey, + safeAuthorizationUrl, + withoutOAuthCallbackParameters, + type OAuthConnectionSummary, +} from "./oauth-connection-state"; + +const summary: OAuthConnectionSummary = { + id: "oauth-1", + credentialKey: "oauth", + revision: 4, + status: "connected", + issuer: "https://identity.example.test", + clientId: "executor", + clientAuthMethod: "client_secret_basic", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback", + requestedScopes: ["read", "write"], + grantedScopes: ["read"], + hasClientSecret: true, + hasRefreshToken: true, + accessExpiresAt: 100, + authorizedAt: 90, + lastRefreshedAt: 95, + errorCode: null, + managedOAuthEligible: true, +}; + +describe("OAuth connection state", () => { + it("builds a confidential-client update with explicit secret preservation", () => { + const draft = draftFromOAuthSummary(summary); + expect(buildOAuthConnectionInput(draft, summary.revision)).toEqual({ + expectedRevision: 4, + discovery: { type: "issuer", issuer: "https://identity.example.test/" }, + client: { + clientId: "executor", + authentication: "client_secret_basic", + clientSecret: { action: "preserve" }, + }, + scopes: ["read", "write"], + }); + }); + + it("makes changing to a public client an explicit secret-clearing operation", () => { + const draft = { ...draftFromOAuthSummary(summary), clientKind: "public" as const }; + expect(buildOAuthConnectionInput(draft, 4)).toEqual({ + expectedRevision: 4, + discovery: { type: "issuer", issuer: "https://identity.example.test/" }, + client: { clientId: "executor", authentication: "none" }, + scopes: ["read", "write"], + }); + }); + + it("requires a replacement value and never trims secret bytes", () => { + const draft = { + ...draftFromOAuthSummary(summary), + clientSecretAction: "replace" as const, + clientSecret: " exact secret ", + }; + expect(buildOAuthConnectionInput(draft, 4)?.client).toEqual({ + clientId: "executor", + authentication: "client_secret_basic", + clientSecret: { action: "replace", value: " exact secret " }, + }); + expect(buildOAuthConnectionInput({ ...draft, clientSecret: "" }, 4)).toBeNull(); + }); + + it("builds MCP protected-resource discovery with an optional server override", () => { + const draft = { + ...draftFromOAuthSummary(summary), + issuer: "", + clientKind: "public" as const, + }; + expect(buildOAuthConnectionInput(draft, 0, "mcp")?.discovery).toEqual({ type: "mcp" }); + expect( + buildOAuthConnectionInput( + { ...draft, issuer: "https://identity.example.test/issuer" }, + 0, + "mcp", + )?.discovery, + ).toEqual({ + type: "mcp", + authorizationServer: "https://identity.example.test/issuer", + }); + }); + + it("normalizes and de-duplicates requested scopes", () => { + expect(normalizeOAuthScopes("read, write\nread profile")).toEqual(["read", "write", "profile"]); + }); + + it("uses only allowlisted callback fields as an opaque refetch key", () => { + const parameters = new URLSearchParams({ + result: "failed", + oauth: "connection-1", + error_description: "provider secret detail", + code: "authorization-code", + }); + expect(oauthCallbackRefreshKey(parameters)).toBe("failed:connection-1"); + expect(oauthCallbackConnectionId("failed:connection-1")).toBe("connection-1"); + parameters.set("result", "provider-specific-value"); + expect(oauthCallbackRefreshKey(parameters)).toBeNull(); + }); + + it("cleans only the fixed callback fields after refetch", () => { + const cleaned = withoutOAuthCallbackParameters( + new URL("https://executor.test/sources?oauth=connection-1&result=success&filter=active#list"), + ); + expect(cleaned.toString()).toBe("https://executor.test/sources?filter=active#list"); + }); + + it("returns fixed callback notices without provider-controlled detail", () => { + expect(oauthCallbackOutcomeNotice("failed:connection-1", true).message).toContain( + "did not complete", + ); + expect(oauthCallbackOutcomeNotice("success:connection-1", true).message).toContain("completed"); + expect(oauthCallbackOutcomeNotice("success:missing", false).message).toContain("no matching"); + }); + + it("handles a callback after loading when no OAuth-capable source can report it", () => { + expect( + oauthCallbackNoticeWithoutEligibleSources("failed:missing", ["mcp_stdio"], false)?.message, + ).toContain("did not complete"); + expect( + oauthCallbackNoticeWithoutEligibleSources("failed:missing", ["openapi"], false), + ).toBeNull(); + expect(oauthCallbackNoticeWithoutEligibleSources("failed:missing", [], true)).toBeNull(); + }); + + it("allows secure authorization destinations and loopback HTTP only", () => { + expect(safeAuthorizationUrl("https://identity.example.test/authorize?state=opaque")).toBe( + "https://identity.example.test/authorize?state=opaque", + ); + expect(safeAuthorizationUrl("http://127.0.0.42:9911/authorize?state=opaque")).toBe( + "http://127.0.0.42:9911/authorize?state=opaque", + ); + expect(safeAuthorizationUrl("http://identity.example.test/authorize")).toBeNull(); + expect(safeAuthorizationUrl("https://operator@identity.example.test/authorize")).toBeNull(); + expect(safeAuthorizationUrl("https://identity.example.test/authorize#token")).toBeNull(); + expect(safeAuthorizationUrl("javascript:alert(1)")).toBeNull(); + }); + + it("rejects unsafe issuer URLs before building a save payload", () => { + const draft = draftFromOAuthSummary(summary); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "http://identity.example.test" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://user@identity.example.test" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://identity.example.test?tenant=a" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://identity.example.test#fragment" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "http://localhost:8443/issuer" }, 4), + ).not.toBeNull(); + }); + + it("disconnects token-bearing statuses without assuming a refresh token exists", () => { + expect(canDisconnectOAuth("connected")).toBe(true); + expect(canDisconnectOAuth("reauthorization_required")).toBe(true); + expect(canDisconnectOAuth("ready_to_connect")).toBe(false); + expect(canDisconnectOAuth("connecting")).toBe(false); + }); +}); diff --git a/web/src/lib/oauth-connection-state.ts b/web/src/lib/oauth-connection-state.ts new file mode 100644 index 000000000..da579d779 --- /dev/null +++ b/web/src/lib/oauth-connection-state.ts @@ -0,0 +1,298 @@ +export type OAuthClientAuthentication = "none" | "client_secret_basic" | "client_secret_post"; + +export type OAuthConnectionStatus = + | "not_configured" + | "ready_to_connect" + | "connecting" + | "connected" + | "reauthorization_required" + | "error"; + +export type OAuthConnectionSummary = { + readonly id: string; + readonly credentialKey: string; + readonly revision: number; + readonly status: OAuthConnectionStatus; + readonly issuer: string; + readonly clientId: string; + readonly clientAuthMethod: OAuthClientAuthentication; + readonly callbackUrl: string; + readonly requestedScopes: readonly string[]; + readonly grantedScopes: readonly string[]; + readonly hasClientSecret: boolean; + readonly hasRefreshToken: boolean; + readonly accessExpiresAt: number | null; + readonly authorizedAt: number | null; + readonly lastRefreshedAt: number | null; + readonly errorCode: string | null; + readonly managedOAuthEligible: boolean; +}; + +export type OAuthConnectionList = { + readonly connections: readonly OAuthConnectionSummary[]; + readonly availableCredentials: readonly OAuthAvailableCredential[]; +}; + +export type OAuthAvailableCredential = { + readonly credentialKey: string; + readonly protocol: "openapi" | "graphql" | "mcp_http"; + readonly requestedScopes: readonly string[]; + readonly managedOAuthEligible: true; +}; + +export type OAuthClientSecretMutation = + | { readonly action: "preserve" } + | { readonly action: "replace"; readonly value: string }; + +export type OAuthConnectionInput = { + readonly expectedRevision: number; + readonly discovery: + | { readonly type: "issuer"; readonly issuer: string } + | { readonly type: "mcp"; readonly authorizationServer?: string }; + readonly client: + | { readonly clientId: string; readonly authentication: "none" } + | { + readonly clientId: string; + readonly authentication: "client_secret_basic" | "client_secret_post"; + readonly clientSecret: OAuthClientSecretMutation; + }; + readonly scopes: readonly string[]; +}; + +export type OAuthAuthorization = { + readonly authorizationUrl: string; +}; + +export type OAuthOperationError = { + readonly code: string; + readonly displayMessage: string; + readonly requestId: string | null; + readonly status: number; +}; + +export type OAuthOperationResult = + | { readonly ok: true; readonly value: Value } + | { readonly ok: false; readonly error: OAuthOperationError }; + +export type OAuthConnectionOperations = { + readonly load: ( + sourceId: string, + signal: AbortSignal, + ) => Promise>; + readonly save: ( + sourceId: string, + credentialKey: string, + input: OAuthConnectionInput, + signal: AbortSignal, + ) => Promise>; + readonly authorize: ( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + signal: AbortSignal, + ) => Promise>; + readonly disconnect: ( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + signal: AbortSignal, + ) => Promise>; + readonly remove: ( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + signal: AbortSignal, + ) => Promise>; +}; + +export type OAuthConnectionDraft = { + readonly issuer: string; + readonly clientKind: "public" | "confidential"; + readonly clientId: string; + readonly clientAuthMethod: "client_secret_basic" | "client_secret_post"; + readonly clientSecretAction: "preserve" | "replace" | "clear"; + readonly clientSecret: string; + readonly scopes: string; +}; + +export function draftFromOAuthSummary(summary: OAuthConnectionSummary): OAuthConnectionDraft { + const confidential = summary.clientAuthMethod !== "none"; + return { + issuer: summary.issuer, + clientKind: confidential ? "confidential" : "public", + clientId: summary.clientId, + clientAuthMethod: confidential ? summary.clientAuthMethod : "client_secret_basic", + clientSecretAction: confidential && summary.hasClientSecret ? "preserve" : "clear", + clientSecret: "", + scopes: summary.requestedScopes.join(" "), + }; +} + +export function emptyOAuthDraft(): OAuthConnectionDraft { + return { + issuer: "", + clientKind: "public", + clientId: "", + clientAuthMethod: "client_secret_basic", + clientSecretAction: "clear", + clientSecret: "", + scopes: "", + }; +} + +export function buildOAuthConnectionInput( + draft: OAuthConnectionDraft, + expectedRevision: number, + discoveryType: "issuer" | "mcp" = "issuer", +): OAuthConnectionInput | null { + const clientId = draft.clientId.trim(); + const issuer = draft.issuer.trim() === "" ? null : normalizedHttpUrl(draft.issuer); + if (clientId === "" || (discoveryType === "issuer" && issuer === null)) return null; + if (draft.issuer.trim() !== "" && issuer === null) return null; + const discovery: OAuthConnectionInput["discovery"] = + discoveryType === "mcp" + ? { + type: "mcp", + ...(issuer === null ? {} : { authorizationServer: issuer }), + } + : { type: "issuer", issuer: issuer ?? "" }; + + const scopes = normalizeOAuthScopes(draft.scopes); + if (draft.clientKind === "public") { + return { + expectedRevision, + discovery, + client: { clientId, authentication: "none" }, + scopes, + }; + } + + if (draft.clientSecretAction === "clear") return null; + if (draft.clientSecretAction === "replace" && draft.clientSecret === "") return null; + const clientSecret = + draft.clientSecretAction === "replace" + ? { action: "replace" as const, value: draft.clientSecret } + : { action: "preserve" as const }; + return { + expectedRevision, + discovery, + client: { + clientId, + authentication: draft.clientAuthMethod, + clientSecret, + }, + scopes, + }; +} + +export function normalizeOAuthScopes(value: string) { + return [...new Set(value.split(/[\s,]+/u).filter(Boolean))]; +} + +export function oauthCallbackRefreshKey(parameters: URLSearchParams) { + const result = parameters.get("result"); + if (result !== "success" && result !== "failed") return null; + const connectionId = parameters.get("oauth"); + if (connectionId === null || connectionId.trim() === "") return null; + return `${result}:${connectionId}`; +} + +export function oauthCallbackConnectionId(refreshKey: string | null) { + if (refreshKey === null) return null; + const separator = refreshKey.indexOf(":"); + return separator < 0 ? null : refreshKey.slice(separator + 1); +} + +export function oauthCallbackOutcomeNotice(refreshKey: string, matched: boolean) { + if (refreshKey.startsWith("failed:")) { + return { + tone: "error" as const, + message: "OAuth authorization did not complete. Review the connection status and try again.", + }; + } + return matched + ? { + tone: "success" as const, + message: "OAuth authorization completed and the connection status was refreshed.", + } + : { + tone: "error" as const, + message: "OAuth returned, but no matching managed connection is available.", + }; +} + +export function oauthCallbackNoticeWithoutEligibleSources( + refreshKey: string | null, + sourceKinds: readonly string[], + loading: boolean, +) { + if ( + refreshKey === null || + loading || + sourceKinds.some((kind) => kind === "openapi" || kind === "graphql" || kind === "mcp_http") + ) { + return null; + } + return oauthCallbackOutcomeNotice(refreshKey, false); +} + +export function withoutOAuthCallbackParameters(value: URL) { + const url = new URL(value); + url.searchParams.delete("oauth"); + url.searchParams.delete("result"); + return url; +} + +export function safeAuthorizationUrl(value: string) { + if (!URL.canParse(value)) return null; + const url = new URL(value); + if (!isSecureOAuthUrl(url) || hasUserInfo(url) || url.hash !== "") return null; + return url.toString(); +} + +export function oauthStatusTone(status: OAuthConnectionStatus) { + if (status === "connected") return "connected"; + if (status === "error" || status === "reauthorization_required") return "error"; + if (status === "connecting") return "pending"; + return "neutral"; +} + +export function oauthStatusLabel(status: OAuthConnectionStatus) { + if (status === "not_configured") return "Not configured"; + if (status === "ready_to_connect") return "Ready to connect"; + if (status === "connecting") return "Connecting"; + if (status === "connected") return "Connected"; + if (status === "reauthorization_required") return "Reconnect required"; + return "Connection error"; +} + +export function canDisconnectOAuth(status: OAuthConnectionStatus) { + return status === "connected" || status === "reauthorization_required"; +} + +function normalizedHttpUrl(value: string) { + const normalized = value.trim(); + if (!URL.canParse(normalized)) return null; + const url = new URL(normalized); + if (!isSecureOAuthUrl(url) || hasUserInfo(url) || url.search !== "" || url.hash !== "") { + return null; + } + return url.toString(); +} + +function isSecureOAuthUrl(url: URL) { + if (url.protocol === "https:") return true; + if (url.protocol !== "http:") return false; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, ""); + if (host === "localhost" || host.endsWith(".localhost") || host === "::1") return true; + const octets = host.split(".").map(Number); + return ( + octets.length === 4 && + octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) && + octets[0] === 127 + ); +} + +function hasUserInfo(url: URL) { + return url.username !== "" || url.password !== ""; +} diff --git a/web/src/routes/sources/+page.svelte b/web/src/routes/sources/+page.svelte index 72d4dfec6..a3f3f27c1 100644 --- a/web/src/routes/sources/+page.svelte +++ b/web/src/routes/sources/+page.svelte @@ -1,18 +1,31 @@ + + diff --git a/web/src/routes/sources/sources-page.test.ts b/web/src/routes/sources/sources-page.test.ts new file mode 100644 index 000000000..07518fe2a --- /dev/null +++ b/web/src/routes/sources/sources-page.test.ts @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import SourcesPageHarness from "./sources-page.test-harness.svelte"; + +function sourceFixture(id = "graphql-source", displayName = "Product API") { + return { + id, + kind: "graphql", + slug: id, + displayName, + description: null, + configuration: { endpoint: "https://api.example.test/", allowPrivateNetwork: false }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("Sources page GraphQL coordination", () => { + it("disables sibling source controls while managed OAuth saves", async () => { + const replacement = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + } + if (path === "/api/v1/sources/graphql-source/oauth/default" && init?.method === "PUT") { + return replacement.promise; + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve( + Response.json({ + connections: [], + availableCredentials: [ + { + credentialKey: "default", + protocol: "graphql", + requestedScopes: [], + managedOAuthEligible: true, + }, + ], + }), + ); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness); + + const manageCredentials = await screen.findByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manageCredentials.disabled).toBe(false)); + await fireEvent.input(screen.getByLabelText("Issuer URL"), { + target: { value: "https://identity.example.test" }, + }); + await fireEvent.input(screen.getByLabelText("Client ID"), { + target: { value: "executor-client" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + await waitFor(() => expect(manageCredentials.disabled).toBe(true)); + expect( + screen.getByRole("button", { name: "Refresh schema and tools" }).disabled, + ).toBe(true); + expect(screen.getByRole("button", { name: "Delete" }).disabled).toBe(true); + + replacement.resolve( + Response.json({ + id: "connection-1", + credentialKey: "default", + revision: 1, + status: "ready_to_connect", + issuer: "https://identity.example.test/", + clientId: "executor-client", + clientAuthMethod: "none", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/connection-1", + requestedScopes: [], + grantedScopes: [], + hasClientSecret: false, + hasRefreshToken: false, + accessExpiresAt: null, + authorizedAt: null, + lastRefreshedAt: null, + errorCode: null, + managedOAuthEligible: true, + }), + ); + await replacement.promise; + }); + + it("disables an open mode confirmation and sibling actions while credentials save", async () => { + const replacement = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + } + if (path.endsWith("/credentials") && init?.method === "PUT") { + return replacement.promise; + } + if (path.endsWith("/credentials")) { + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness); + + await screen.findByRole("heading", { name: "Product API" }); + await fireEvent.click(screen.getByLabelText("Enabled")); + await fireEvent.click(screen.getByRole("button", { name: "Apply source default" })); + const confirmMode = screen.getByRole("button", { + name: "Confirm broad change", + }); + expect(confirmMode.disabled).toBe(false); + + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "credential-secret" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(confirmMode.disabled).toBe(true)); + expect( + screen.getByRole("button", { name: "Refresh schema and tools" }).disabled, + ).toBe(true); + expect(screen.getByRole("button", { name: "Delete" }).disabled).toBe(true); + + replacement.resolve( + Response.json({ + revision: 5, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + await replacement.promise; + }); + + it("does not abort a source save while a different source refreshes the list", async () => { + const replacement = deferred(); + const saveRequest = { signal: null as AbortSignal | null }; + let listCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: [ + sourceFixture("source-a", "Source A"), + sourceFixture("source-b", "Source B"), + ], + catalogRevision: listCalls, + }), + ); + } + if (path === "/api/v1/sources/source-a/credentials" && init?.method === "PUT") { + saveRequest.signal = init.signal ?? null; + return replacement.promise; + } + if (path === "/api/v1/sources/source-a/credentials") { + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + } + if (path === "/api/v1/sources/source-b/refresh" && init?.method === "POST") { + return Promise.resolve( + Response.json({ + sourceId: "source-b", + sourceRevision: 2, + catalogRevision: 2, + globalRevision: 2, + activeToolCount: 4, + tombstonedToolCount: 0, + }), + ); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness); + + const sourceAHeading = await screen.findByRole("heading", { name: "Source A" }); + const sourceBHeading = screen.getByRole("heading", { name: "Source B" }); + const sourceA = within(sourceAHeading.closest("article") ?? document.body); + const sourceB = within(sourceBHeading.closest("article") ?? document.body); + await fireEvent.click(sourceA.getByRole("button", { name: "Manage credentials" })); + const token = await sourceA.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "source-a-secret" } }); + await fireEvent.click(sourceA.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(saveRequest.signal).not.toBeNull()); + + await fireEvent.click(sourceB.getByRole("button", { name: "Refresh schema and tools" })); + await waitFor(() => expect(listCalls).toBe(2)); + expect(saveRequest.signal?.aborted).toBe(false); + + replacement.resolve( + Response.json({ + revision: 5, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + await replacement.promise; + expect(await sourceA.findByText(/Credentials replaced/)).toBeDefined(); + }); +}); From bc09b8398be4f54e2a9751420b242b43f601b0d8 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Wed, 24 Jun 2026 04:20:04 -0700 Subject: [PATCH 07/24] feat: package the self-hosted release --- .../wrdn-effect-atom-optimistic/SKILL.md | 2 +- .changeset/config.json | 3 +- .dockerignore | 3 + .github/workflows/build-release-artifacts.yml | 214 ++++ .github/workflows/ci.yml | 190 +++- .github/workflows/publish-desktop.yml | 38 +- .../workflows/publish-executor-package.yml | 5 - .github/workflows/release.yml | 4 - .gitignore | 8 +- .oxfmtrc.json | 4 +- .oxlintrc.jsonc | 4 +- .skills/cli-release/SKILL.md | 4 +- .skills/warden-security-review/SKILL.md | 6 +- AGENTS.md | 5 +- Cargo.toml | 26 +- Dockerfile | 73 ++ Dockerfile.dockerignore | 29 + README.md | 276 ++--- RELEASING.md | 4 +- RUNNING.md | 312 +++--- about.hbs | 27 + about.toml | 32 + apps/docs/README.md | 2 +- apps/host-selfhost/src/db/self-host-db.ts | 2 +- apps/host-selfhost/src/plugins.ts | 2 +- apps/marketing/astro.config.mjs | 2 +- apps/marketing/wrangler.toml | 2 +- build.rs | 299 ++++++ bun.lock | 960 +++--------------- compose.yaml | 65 ++ docker/mcp-stdio-templates.example.json | 3 + docker/mcp-stdio-templates.full.example.json | 14 + docs/cli.md | 26 +- docs/docker.md | 102 ++ docs/install.md | 177 ++++ docs/launchd.md | 106 ++ docs/sources.md | 194 ++++ docs/systemd.md | 146 +++ e2e/AGENTS.md | 14 +- e2e/cloud/auth-session.test.ts | 2 +- e2e/cloud/mcp-client-sessions.test.ts | 2 +- e2e/cloud/mcp-protocol.test.ts | 2 +- e2e/cloud/oauth-connections.test.ts | 2 +- e2e/cloud/sources-api.test.ts | 2 +- e2e/cloud/sources-refresh.test.ts | 2 +- e2e/cloud/unauthenticated-skeleton.test.ts | 2 +- e2e/desktop/local-auth-mcp.test.ts | 2 +- e2e/desktop/reset-state.test.ts | 2 +- e2e/desktop/sidecar-crash-screen.test.ts | 4 +- e2e/local-selfhost/core-journeys.test.ts | 625 ++++++++++++ e2e/package.json | 16 +- e2e/scripts/ports.ts | 3 + e2e/setup/cloud.boot.ts | 2 +- e2e/setup/desktop-packaged.globalsetup.ts | 2 +- e2e/setup/desktop.globalsetup.ts | 2 +- e2e/setup/local-selfhost.globalsetup.ts | 140 +++ e2e/src/local-admin.ts | 151 +++ e2e/src/oauth-test-provider.test.ts | 30 + e2e/src/oauth-test-provider.ts | 46 + e2e/src/scenario-redaction.test.ts | 36 + e2e/src/scenario.ts | 14 +- e2e/src/surfaces/browser.ts | 62 +- e2e/targets/local-selfhost.ts | 53 + e2e/targets/registry.ts | 2 + e2e/vitest.config.ts | 109 +- e2e/vitest.legacy.config.ts | 59 ++ .../vitest.unit.config.ts | 0 knip.config.ts | 4 +- legacy/README.md | 17 + {apps => legacy}/cloud/.gitignore | 0 {apps => legacy}/cloud/CHANGELOG.md | 0 {apps => legacy}/cloud/drizzle.config.ts | 0 .../cloud/drizzle/0000_v2_baseline.sql | 0 .../cloud/drizzle/0001_illegal_wolverine.sql | 0 .../0002_unwrap_openapi_output_envelope.sql | 0 .../drizzle/0003_friendly_monster_badoon.sql | 0 .../cloud/drizzle/0004_nervous_quasar.sql | 0 .../drizzle/0005_integration_descriptions.sql | 0 .../drizzle/0006_google_openapi_ownership.sql | 0 .../cloud/drizzle/0007_red_dragon_lord.sql | 0 .../cloud/drizzle/meta/0000_snapshot.json | 0 .../cloud/drizzle/meta/0001_snapshot.json | 0 .../cloud/drizzle/meta/0002_snapshot.json | 0 .../cloud/drizzle/meta/0003_snapshot.json | 0 .../cloud/drizzle/meta/0004_snapshot.json | 0 .../cloud/drizzle/meta/0005_snapshot.json | 0 .../cloud/drizzle/meta/0006_snapshot.json | 0 .../cloud/drizzle/meta/0007_snapshot.json | 0 .../cloud/drizzle/meta/_journal.json | 0 {apps => legacy}/cloud/executor.config.ts | 0 {apps => legacy}/cloud/package.json | 0 .../cloud/public/apple-touch-icon.png | Bin {apps => legacy}/cloud/public/favicon-192.png | Bin {apps => legacy}/cloud/public/favicon-32.png | Bin {apps => legacy}/cloud/public/favicon.ico | Bin .../cloud/scripts/backfill-org-slugs.ts | 0 {apps => legacy}/cloud/scripts/build.mjs | 0 .../google-openapi-r2-blobs.ts | 0 .../cloud/scripts/code-migrations/index.ts | 0 .../cloud/scripts/code-migrations/runner.ts | 0 {apps => legacy}/cloud/scripts/dev-db.ts | 0 {apps => legacy}/cloud/scripts/gen-routes.ts | 2 +- .../cloud/scripts/migrate-auth-configs.ts | 0 .../cloud/scripts/migrate-specs-to-blobs.ts | 0 {apps => legacy}/cloud/scripts/migrate.ts | 0 .../scripts/repartition-vault-metadata.ts | 0 .../cloud/scripts/test-globalsetup.ts | 0 .../cloud/src/account/account-api.ts | 0 .../src/account/member-limits.node.test.ts | 0 .../account/organization-limits.node.test.ts | 0 .../src/account/workos-account-service.ts | 0 .../cloud/src/api.request-scope.node.test.ts | 0 .../cloud/src/api/error-response.ts | 0 {apps => legacy}/cloud/src/api/layers.ts | 0 .../api/protected-api-key-auth.node.test.ts | 0 .../src/api/protected-jwt-auth.node.test.ts | 0 .../cloud/src/api/protected.test.ts | 0 {apps => legacy}/cloud/src/api/protected.ts | 4 +- {apps => legacy}/cloud/src/api/router.ts | 2 +- {apps => legacy}/cloud/src/app-paths.test.ts | 0 {apps => legacy}/cloud/src/app-paths.ts | 0 {apps => legacy}/cloud/src/app.ts | 0 .../cloud/src/auth/api-jwt-bearer.ts | 0 .../cloud/src/auth/api-keys.node.test.ts | 0 {apps => legacy}/cloud/src/auth/api-keys.ts | 0 {apps => legacy}/cloud/src/auth/api.ts | 0 {apps => legacy}/cloud/src/auth/bearer.ts | 0 {apps => legacy}/cloud/src/auth/context.ts | 0 {apps => legacy}/cloud/src/auth/cookies.ts | 0 {apps => legacy}/cloud/src/auth/errors.ts | 0 {apps => legacy}/cloud/src/auth/handlers.ts | 0 .../cloud/src/auth/jwks-cache.node.test.ts | 0 {apps => legacy}/cloud/src/auth/jwks-cache.ts | 0 .../cloud/src/auth/login-state.test.ts | 0 .../cloud/src/auth/login-state.ts | 0 .../cloud/src/auth/middleware-live.ts | 0 {apps => legacy}/cloud/src/auth/middleware.ts | 0 .../src/auth/org-selector-auth.node.test.ts | 0 .../cloud/src/auth/organization.ts | 0 .../cloud/src/auth/request-origin.test.ts | 0 .../cloud/src/auth/request-origin.ts | 0 .../cloud/src/auth/return-to.test.ts | 0 {apps => legacy}/cloud/src/auth/return-to.ts | 0 .../cloud/src/auth/route-paths.ts | 0 {apps => legacy}/cloud/src/auth/ssr-gate.ts | 0 {apps => legacy}/cloud/src/auth/user-store.ts | 0 .../cloud/src/auth/workos-auth-provider.ts | 0 .../cloud/src/auth/workos.node.test.ts | 0 {apps => legacy}/cloud/src/auth/workos.ts | 0 .../src/db/code-migration-runner.test.ts | 0 .../cloud/src/db/db.schema.test.ts | 0 {apps => legacy}/cloud/src/db/db.test.ts | 0 {apps => legacy}/cloud/src/db/db.ts | 2 +- .../cloud/src/db/executor-schema.ts | 0 {apps => legacy}/cloud/src/db/fuma.ts | 0 .../google-openapi-r2-code-migration.test.ts | 0 {apps => legacy}/cloud/src/db/schema.ts | 0 {apps => legacy}/cloud/src/edge/docs.test.ts | 0 {apps => legacy}/cloud/src/edge/docs.ts | 0 {apps => legacy}/cloud/src/edge/index.ts | 0 {apps => legacy}/cloud/src/edge/marketing.ts | 0 {apps => legacy}/cloud/src/edge/posthog.ts | 0 .../cloud/src/edge/sentry-tunnel.ts | 0 .../src/engine/execution-stack-metered.ts | 0 .../cloud/src/engine/execution-stack.ts | 0 .../cloud/src/engine/execution-usage.ts | 0 {apps => legacy}/cloud/src/env-augment.d.ts | 0 .../cloud/src/extensions/billing/plans.ts | 0 .../src/extensions/billing/route.node.test.ts | 0 .../cloud/src/extensions/billing/route.ts | 0 .../cloud/src/extensions/billing/service.ts | 0 {apps => legacy}/cloud/src/extensions/docs.ts | 0 .../cloud/src/extensions/routes.ts | 0 .../frontend-atom-error-capture.node.test.ts | 0 .../cloud/src/mcp-session.e2e.node.test.ts | 0 .../cloud/src/mcp/auth-provider.ts | 0 {apps => legacy}/cloud/src/mcp/auth.ts | 0 {apps => legacy}/cloud/src/mcp/index.ts | 0 {apps => legacy}/cloud/src/mcp/jwt.ts | 0 .../cloud/src/mcp/mcp-auth.node.test.ts | 0 {apps => legacy}/cloud/src/mcp/mount.ts | 0 .../cloud/src/mcp/oauth-metadata.ts | 0 {apps => legacy}/cloud/src/mcp/reporter.ts | 0 {apps => legacy}/cloud/src/mcp/responses.ts | 0 .../cloud/src/mcp/session-durable-object.ts | 0 .../cloud/src/mcp/session-store.ts | 0 {apps => legacy}/cloud/src/mcp/telemetry.ts | 0 .../cloud/src/mcp/worker-transport.test.ts | 0 .../src/observability/browser-traces.test.ts | 0 .../cloud/src/observability/browser-traces.ts | 0 .../cloud/src/observability/error-logging.ts | 0 .../cloud/src/observability/index.ts | 0 .../src/observability/observability.test.ts | 0 .../cloud/src/observability/telemetry.ts | 0 {apps => legacy}/cloud/src/org/api.ts | 0 .../cloud/src/org/handlers.test.ts | 0 {apps => legacy}/cloud/src/org/handlers.ts | 0 {apps => legacy}/cloud/src/plugins.ts | 0 {apps => legacy}/cloud/src/routeTree.gen.ts | 0 {apps => legacy}/cloud/src/router.tsx | 0 {apps => legacy}/cloud/src/routes/__root.tsx | 0 .../cloud/src/routes/app/api-keys.tsx | 0 .../cloud/src/routes/app/billing.tsx | 0 .../cloud/src/routes/app/billing_.plans.tsx | 0 {apps => legacy}/cloud/src/routes/app/org.tsx | 0 .../src/routes/app/resume.$executionId.tsx | 0 .../cloud/src/routes/app/secrets.tsx | 0 .../cloud/src/routes/bare/create-org.tsx | 0 .../cloud/src/routes/bare/login.tsx | 0 .../cloud/src/routes/bare/setup-mcp.tsx | 0 {apps => legacy}/cloud/src/server.ts | 0 {apps => legacy}/cloud/src/start.ts | 0 {apps => legacy}/cloud/src/vite-env.d.ts | 0 {apps => legacy}/cloud/src/web/auth.tsx | 0 {apps => legacy}/cloud/src/web/client.tsx | 0 .../components/create-organization-form.tsx | 0 .../src/web/components/org-menu-slot.tsx | 0 .../src/web/components/support-options.tsx | 0 .../cloud/src/web/components/support-slot.tsx | 0 .../cloud/src/web/mcp-resume-atoms.ts | 0 {apps => legacy}/cloud/src/web/org-atoms.ts | 0 .../cloud/src/web/pages/create-org.tsx | 0 .../cloud/src/web/pages/login.tsx | 0 .../cloud/src/web/pages/setup-mcp.tsx | 0 {apps => legacy}/cloud/src/web/shell.tsx | 0 .../cloud/test-stubs/cloudflare-workers.ts | 0 .../cloud/test-stubs/tanstack-start-entry.ts | 0 {apps => legacy}/cloud/tsconfig.json | 0 {apps => legacy}/cloud/tsr.routes.ts | 0 {apps => legacy}/cloud/vite.config.ts | 0 {apps => legacy}/cloud/vitest.config.ts | 0 .../cloud/worker-configuration.d.ts | 0 {apps => legacy}/cloud/wrangler.jsonc | 0 {apps => legacy}/desktop/.gitignore | 0 {apps => legacy}/desktop/CHANGELOG.md | 0 .../desktop/build/entitlements.mac.plist | 0 {apps => legacy}/desktop/build/icon.png | Bin .../desktop/electron-builder.config.ts | 0 .../desktop/electron-builder.e2e.config.ts | 0 .../desktop/electron.vite.config.ts | 2 +- {apps => legacy}/desktop/package.json | 0 .../desktop/scripts/build-sidecar.ts | 0 .../desktop/scripts/smoke-sidecar.ts | 4 +- .../desktop/src/main/crash-screen.ts | 0 .../desktop/src/main/diagnostics.ts | 0 {apps => legacy}/desktop/src/main/global.d.ts | 0 {apps => legacy}/desktop/src/main/index.ts | 2 +- .../desktop/src/main/local-auth.ts | 0 .../desktop/src/main/reset-state.ts | 0 {apps => legacy}/desktop/src/main/service.ts | 0 {apps => legacy}/desktop/src/main/settings.ts | 0 {apps => legacy}/desktop/src/main/sidecar.ts | 4 +- .../src/main/supervised-connection.test.ts | 0 .../desktop/src/main/supervised-connection.ts | 0 .../src/main/supervised-daemon.test.ts | 0 .../desktop/src/main/supervised-daemon.ts | 0 {apps => legacy}/desktop/src/preload/index.ts | 0 .../desktop/src/renderer/index.html | 0 .../desktop/src/shared/server-settings.ts | 0 .../desktop/src/sidecar/native-bindings.ts | 0 .../desktop/src/sidecar/server.ts | 0 {apps => legacy}/desktop/tsconfig.json | 0 legacy/desktop/vitest.config.ts | 7 + package.json | 32 +- packages/app/vite.ts | 2 +- packages/core/api/src/observability.ts | 2 +- .../core/api/src/server/request-scoped.ts | 2 +- packages/core/execution/src/promise.ts | 2 +- .../hosts/mcp/src/browser-approval-store.ts | 2 +- .../plugins/desktop-settings/src/client.tsx | 2 +- .../src/sdk/output-schema-migration.ts | 2 +- .../openapi/src/sdk/real-specs.test.ts | 2 +- packages/react/src/styles/globals.css | 1 - packaging/launchd/bounded-log.sh | 60 ++ packaging/launchd/dev.executor.gateway.plist | 27 + packaging/systemd/executor.env.example | 10 + packaging/systemd/executor.service | 58 ++ .../systemd/mcp-stdio-templates.example.json | 14 + scripts/bootstrap.ts | 2 +- scripts/clean.ts | 2 +- scripts/install-launchd.sh | 204 ++++ scripts/install-systemd.sh | 222 ++++ scripts/install.sh | 447 ++++---- .../no-cross-package-relative-imports.js | 2 +- .../no-direct-cloud-executor-schema-import.js | 7 +- scripts/reap-dev-servers.ts | 2 +- scripts/run-local-e2e.ts | 22 + scripts/test-install-release-archive.sh | 61 ++ src/api.rs | 16 +- src/web_assets.rs | 420 +++++++- tests/control_plane.rs | 2 +- .../_app/immutable/assets/app.fixture.css | 3 + .../_app/immutable/entry/start.fixture.js | 1 + tests/fixtures/web-assets/index.html | 15 + tests/web_assets.rs | 141 +++ warden.toml | 14 +- web/CHANGELOG.md | 1 + web/README.md | 25 +- web/src/lib/McpStdioSourceForm.svelte | 6 +- web/src/lib/McpStdioSourceForm.test.ts | 4 + web/vite.config.ts | 5 + 301 files changed, 5037 insertions(+), 1614 deletions(-) create mode 100644 .github/workflows/build-release-artifacts.yml create mode 100644 Dockerfile create mode 100644 Dockerfile.dockerignore create mode 100644 about.hbs create mode 100644 about.toml create mode 100644 build.rs create mode 100644 compose.yaml create mode 100644 docker/mcp-stdio-templates.example.json create mode 100644 docker/mcp-stdio-templates.full.example.json create mode 100644 docs/docker.md create mode 100644 docs/install.md create mode 100644 docs/launchd.md create mode 100644 docs/sources.md create mode 100644 docs/systemd.md create mode 100644 e2e/local-selfhost/core-journeys.test.ts create mode 100644 e2e/setup/local-selfhost.globalsetup.ts create mode 100644 e2e/src/local-admin.ts create mode 100644 e2e/src/oauth-test-provider.test.ts create mode 100644 e2e/src/oauth-test-provider.ts create mode 100644 e2e/src/scenario-redaction.test.ts create mode 100644 e2e/targets/local-selfhost.ts create mode 100644 e2e/vitest.legacy.config.ts rename apps/desktop/vitest.config.ts => e2e/vitest.unit.config.ts (100%) create mode 100644 legacy/README.md rename {apps => legacy}/cloud/.gitignore (100%) rename {apps => legacy}/cloud/CHANGELOG.md (100%) rename {apps => legacy}/cloud/drizzle.config.ts (100%) rename {apps => legacy}/cloud/drizzle/0000_v2_baseline.sql (100%) rename {apps => legacy}/cloud/drizzle/0001_illegal_wolverine.sql (100%) rename {apps => legacy}/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql (100%) rename {apps => legacy}/cloud/drizzle/0003_friendly_monster_badoon.sql (100%) rename {apps => legacy}/cloud/drizzle/0004_nervous_quasar.sql (100%) rename {apps => legacy}/cloud/drizzle/0005_integration_descriptions.sql (100%) rename {apps => legacy}/cloud/drizzle/0006_google_openapi_ownership.sql (100%) rename {apps => legacy}/cloud/drizzle/0007_red_dragon_lord.sql (100%) rename {apps => legacy}/cloud/drizzle/meta/0000_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/0001_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/0002_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/0003_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/0004_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/0005_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/0006_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/0007_snapshot.json (100%) rename {apps => legacy}/cloud/drizzle/meta/_journal.json (100%) rename {apps => legacy}/cloud/executor.config.ts (100%) rename {apps => legacy}/cloud/package.json (100%) rename {apps => legacy}/cloud/public/apple-touch-icon.png (100%) rename {apps => legacy}/cloud/public/favicon-192.png (100%) rename {apps => legacy}/cloud/public/favicon-32.png (100%) rename {apps => legacy}/cloud/public/favicon.ico (100%) rename {apps => legacy}/cloud/scripts/backfill-org-slugs.ts (100%) rename {apps => legacy}/cloud/scripts/build.mjs (100%) rename {apps => legacy}/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts (100%) rename {apps => legacy}/cloud/scripts/code-migrations/index.ts (100%) rename {apps => legacy}/cloud/scripts/code-migrations/runner.ts (100%) rename {apps => legacy}/cloud/scripts/dev-db.ts (100%) rename {apps => legacy}/cloud/scripts/gen-routes.ts (95%) rename {apps => legacy}/cloud/scripts/migrate-auth-configs.ts (100%) rename {apps => legacy}/cloud/scripts/migrate-specs-to-blobs.ts (100%) rename {apps => legacy}/cloud/scripts/migrate.ts (100%) rename {apps => legacy}/cloud/scripts/repartition-vault-metadata.ts (100%) rename {apps => legacy}/cloud/scripts/test-globalsetup.ts (100%) rename {apps => legacy}/cloud/src/account/account-api.ts (100%) rename {apps => legacy}/cloud/src/account/member-limits.node.test.ts (100%) rename {apps => legacy}/cloud/src/account/organization-limits.node.test.ts (100%) rename {apps => legacy}/cloud/src/account/workos-account-service.ts (100%) rename {apps => legacy}/cloud/src/api.request-scope.node.test.ts (100%) rename {apps => legacy}/cloud/src/api/error-response.ts (100%) rename {apps => legacy}/cloud/src/api/layers.ts (100%) rename {apps => legacy}/cloud/src/api/protected-api-key-auth.node.test.ts (100%) rename {apps => legacy}/cloud/src/api/protected-jwt-auth.node.test.ts (100%) rename {apps => legacy}/cloud/src/api/protected.test.ts (100%) rename {apps => legacy}/cloud/src/api/protected.ts (98%) rename {apps => legacy}/cloud/src/api/router.ts (97%) rename {apps => legacy}/cloud/src/app-paths.test.ts (100%) rename {apps => legacy}/cloud/src/app-paths.ts (100%) rename {apps => legacy}/cloud/src/app.ts (100%) rename {apps => legacy}/cloud/src/auth/api-jwt-bearer.ts (100%) rename {apps => legacy}/cloud/src/auth/api-keys.node.test.ts (100%) rename {apps => legacy}/cloud/src/auth/api-keys.ts (100%) rename {apps => legacy}/cloud/src/auth/api.ts (100%) rename {apps => legacy}/cloud/src/auth/bearer.ts (100%) rename {apps => legacy}/cloud/src/auth/context.ts (100%) rename {apps => legacy}/cloud/src/auth/cookies.ts (100%) rename {apps => legacy}/cloud/src/auth/errors.ts (100%) rename {apps => legacy}/cloud/src/auth/handlers.ts (100%) rename {apps => legacy}/cloud/src/auth/jwks-cache.node.test.ts (100%) rename {apps => legacy}/cloud/src/auth/jwks-cache.ts (100%) rename {apps => legacy}/cloud/src/auth/login-state.test.ts (100%) rename {apps => legacy}/cloud/src/auth/login-state.ts (100%) rename {apps => legacy}/cloud/src/auth/middleware-live.ts (100%) rename {apps => legacy}/cloud/src/auth/middleware.ts (100%) rename {apps => legacy}/cloud/src/auth/org-selector-auth.node.test.ts (100%) rename {apps => legacy}/cloud/src/auth/organization.ts (100%) rename {apps => legacy}/cloud/src/auth/request-origin.test.ts (100%) rename {apps => legacy}/cloud/src/auth/request-origin.ts (100%) rename {apps => legacy}/cloud/src/auth/return-to.test.ts (100%) rename {apps => legacy}/cloud/src/auth/return-to.ts (100%) rename {apps => legacy}/cloud/src/auth/route-paths.ts (100%) rename {apps => legacy}/cloud/src/auth/ssr-gate.ts (100%) rename {apps => legacy}/cloud/src/auth/user-store.ts (100%) rename {apps => legacy}/cloud/src/auth/workos-auth-provider.ts (100%) rename {apps => legacy}/cloud/src/auth/workos.node.test.ts (100%) rename {apps => legacy}/cloud/src/auth/workos.ts (100%) rename {apps => legacy}/cloud/src/db/code-migration-runner.test.ts (100%) rename {apps => legacy}/cloud/src/db/db.schema.test.ts (100%) rename {apps => legacy}/cloud/src/db/db.test.ts (100%) rename {apps => legacy}/cloud/src/db/db.ts (98%) rename {apps => legacy}/cloud/src/db/executor-schema.ts (100%) rename {apps => legacy}/cloud/src/db/fuma.ts (100%) rename {apps => legacy}/cloud/src/db/google-openapi-r2-code-migration.test.ts (100%) rename {apps => legacy}/cloud/src/db/schema.ts (100%) rename {apps => legacy}/cloud/src/edge/docs.test.ts (100%) rename {apps => legacy}/cloud/src/edge/docs.ts (100%) rename {apps => legacy}/cloud/src/edge/index.ts (100%) rename {apps => legacy}/cloud/src/edge/marketing.ts (100%) rename {apps => legacy}/cloud/src/edge/posthog.ts (100%) rename {apps => legacy}/cloud/src/edge/sentry-tunnel.ts (100%) rename {apps => legacy}/cloud/src/engine/execution-stack-metered.ts (100%) rename {apps => legacy}/cloud/src/engine/execution-stack.ts (100%) rename {apps => legacy}/cloud/src/engine/execution-usage.ts (100%) rename {apps => legacy}/cloud/src/env-augment.d.ts (100%) rename {apps => legacy}/cloud/src/extensions/billing/plans.ts (100%) rename {apps => legacy}/cloud/src/extensions/billing/route.node.test.ts (100%) rename {apps => legacy}/cloud/src/extensions/billing/route.ts (100%) rename {apps => legacy}/cloud/src/extensions/billing/service.ts (100%) rename {apps => legacy}/cloud/src/extensions/docs.ts (100%) rename {apps => legacy}/cloud/src/extensions/routes.ts (100%) rename {apps => legacy}/cloud/src/frontend-atom-error-capture.node.test.ts (100%) rename {apps => legacy}/cloud/src/mcp-session.e2e.node.test.ts (100%) rename {apps => legacy}/cloud/src/mcp/auth-provider.ts (100%) rename {apps => legacy}/cloud/src/mcp/auth.ts (100%) rename {apps => legacy}/cloud/src/mcp/index.ts (100%) rename {apps => legacy}/cloud/src/mcp/jwt.ts (100%) rename {apps => legacy}/cloud/src/mcp/mcp-auth.node.test.ts (100%) rename {apps => legacy}/cloud/src/mcp/mount.ts (100%) rename {apps => legacy}/cloud/src/mcp/oauth-metadata.ts (100%) rename {apps => legacy}/cloud/src/mcp/reporter.ts (100%) rename {apps => legacy}/cloud/src/mcp/responses.ts (100%) rename {apps => legacy}/cloud/src/mcp/session-durable-object.ts (100%) rename {apps => legacy}/cloud/src/mcp/session-store.ts (100%) rename {apps => legacy}/cloud/src/mcp/telemetry.ts (100%) rename {apps => legacy}/cloud/src/mcp/worker-transport.test.ts (100%) rename {apps => legacy}/cloud/src/observability/browser-traces.test.ts (100%) rename {apps => legacy}/cloud/src/observability/browser-traces.ts (100%) rename {apps => legacy}/cloud/src/observability/error-logging.ts (100%) rename {apps => legacy}/cloud/src/observability/index.ts (100%) rename {apps => legacy}/cloud/src/observability/observability.test.ts (100%) rename {apps => legacy}/cloud/src/observability/telemetry.ts (100%) rename {apps => legacy}/cloud/src/org/api.ts (100%) rename {apps => legacy}/cloud/src/org/handlers.test.ts (100%) rename {apps => legacy}/cloud/src/org/handlers.ts (100%) rename {apps => legacy}/cloud/src/plugins.ts (100%) rename {apps => legacy}/cloud/src/routeTree.gen.ts (100%) rename {apps => legacy}/cloud/src/router.tsx (100%) rename {apps => legacy}/cloud/src/routes/__root.tsx (100%) rename {apps => legacy}/cloud/src/routes/app/api-keys.tsx (100%) rename {apps => legacy}/cloud/src/routes/app/billing.tsx (100%) rename {apps => legacy}/cloud/src/routes/app/billing_.plans.tsx (100%) rename {apps => legacy}/cloud/src/routes/app/org.tsx (100%) rename {apps => legacy}/cloud/src/routes/app/resume.$executionId.tsx (100%) rename {apps => legacy}/cloud/src/routes/app/secrets.tsx (100%) rename {apps => legacy}/cloud/src/routes/bare/create-org.tsx (100%) rename {apps => legacy}/cloud/src/routes/bare/login.tsx (100%) rename {apps => legacy}/cloud/src/routes/bare/setup-mcp.tsx (100%) rename {apps => legacy}/cloud/src/server.ts (100%) rename {apps => legacy}/cloud/src/start.ts (100%) rename {apps => legacy}/cloud/src/vite-env.d.ts (100%) rename {apps => legacy}/cloud/src/web/auth.tsx (100%) rename {apps => legacy}/cloud/src/web/client.tsx (100%) rename {apps => legacy}/cloud/src/web/components/create-organization-form.tsx (100%) rename {apps => legacy}/cloud/src/web/components/org-menu-slot.tsx (100%) rename {apps => legacy}/cloud/src/web/components/support-options.tsx (100%) rename {apps => legacy}/cloud/src/web/components/support-slot.tsx (100%) rename {apps => legacy}/cloud/src/web/mcp-resume-atoms.ts (100%) rename {apps => legacy}/cloud/src/web/org-atoms.ts (100%) rename {apps => legacy}/cloud/src/web/pages/create-org.tsx (100%) rename {apps => legacy}/cloud/src/web/pages/login.tsx (100%) rename {apps => legacy}/cloud/src/web/pages/setup-mcp.tsx (100%) rename {apps => legacy}/cloud/src/web/shell.tsx (100%) rename {apps => legacy}/cloud/test-stubs/cloudflare-workers.ts (100%) rename {apps => legacy}/cloud/test-stubs/tanstack-start-entry.ts (100%) rename {apps => legacy}/cloud/tsconfig.json (100%) rename {apps => legacy}/cloud/tsr.routes.ts (100%) rename {apps => legacy}/cloud/vite.config.ts (100%) rename {apps => legacy}/cloud/vitest.config.ts (100%) rename {apps => legacy}/cloud/worker-configuration.d.ts (100%) rename {apps => legacy}/cloud/wrangler.jsonc (100%) rename {apps => legacy}/desktop/.gitignore (100%) rename {apps => legacy}/desktop/CHANGELOG.md (100%) rename {apps => legacy}/desktop/build/entitlements.mac.plist (100%) rename {apps => legacy}/desktop/build/icon.png (100%) rename {apps => legacy}/desktop/electron-builder.config.ts (100%) rename {apps => legacy}/desktop/electron-builder.e2e.config.ts (100%) rename {apps => legacy}/desktop/electron.vite.config.ts (96%) rename {apps => legacy}/desktop/package.json (100%) rename {apps => legacy}/desktop/scripts/build-sidecar.ts (100%) rename {apps => legacy}/desktop/scripts/smoke-sidecar.ts (99%) rename {apps => legacy}/desktop/src/main/crash-screen.ts (100%) rename {apps => legacy}/desktop/src/main/diagnostics.ts (100%) rename {apps => legacy}/desktop/src/main/global.d.ts (100%) rename {apps => legacy}/desktop/src/main/index.ts (99%) rename {apps => legacy}/desktop/src/main/local-auth.ts (100%) rename {apps => legacy}/desktop/src/main/reset-state.ts (100%) rename {apps => legacy}/desktop/src/main/service.ts (100%) rename {apps => legacy}/desktop/src/main/settings.ts (100%) rename {apps => legacy}/desktop/src/main/sidecar.ts (99%) rename {apps => legacy}/desktop/src/main/supervised-connection.test.ts (100%) rename {apps => legacy}/desktop/src/main/supervised-connection.ts (100%) rename {apps => legacy}/desktop/src/main/supervised-daemon.test.ts (100%) rename {apps => legacy}/desktop/src/main/supervised-daemon.ts (100%) rename {apps => legacy}/desktop/src/preload/index.ts (100%) rename {apps => legacy}/desktop/src/renderer/index.html (100%) rename {apps => legacy}/desktop/src/shared/server-settings.ts (100%) rename {apps => legacy}/desktop/src/sidecar/native-bindings.ts (100%) rename {apps => legacy}/desktop/src/sidecar/server.ts (100%) rename {apps => legacy}/desktop/tsconfig.json (100%) create mode 100644 legacy/desktop/vitest.config.ts create mode 100755 packaging/launchd/bounded-log.sh create mode 100644 packaging/launchd/dev.executor.gateway.plist create mode 100644 packaging/systemd/executor.env.example create mode 100644 packaging/systemd/executor.service create mode 100644 packaging/systemd/mcp-stdio-templates.example.json create mode 100755 scripts/install-launchd.sh create mode 100755 scripts/install-systemd.sh create mode 100644 scripts/run-local-e2e.ts create mode 100755 scripts/test-install-release-archive.sh create mode 100644 tests/fixtures/web-assets/_app/immutable/assets/app.fixture.css create mode 100644 tests/fixtures/web-assets/_app/immutable/entry/start.fixture.js create mode 100644 tests/fixtures/web-assets/index.html create mode 100644 tests/web_assets.rs create mode 100644 web/CHANGELOG.md diff --git a/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md b/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md index 44b6ef488..1d4aff600 100644 --- a/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md +++ b/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md @@ -41,7 +41,7 @@ When the trace cannot resolve with the files at hand, drop the finding. - `useState` for a "busy" / "submitting" boolean used to disable a button while the mutation runs. That is not optimistic state. - `setTimeout` / `setInterval` based debouncing or rate-limiting around a mutation. Different concern. - Toast / error-message state. UI feedback, not optimistic data. -- Server-only code (`apps/cloud`, `apps/local`, `packages/core/**`). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers. +- Server-only code (`legacy/cloud`, `apps/local`, `packages/core/**`). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers. - Storybook files, test files, and example-only code. The pattern matters in shipped UI; not in fixtures. ## Severity ladder diff --git a/.changeset/config.json b/.changeset/config.json index 56e35abe8..2d91ccb54 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -17,8 +17,7 @@ "@executor-js/plugin-onepassword", "@executor-js/plugin-openapi", "@executor-js/plugin-example", - "@executor-js/plugin-desktop-settings", - "@executor-js/desktop" + "@executor-js/plugin-desktop-settings" ] ], "linked": [], diff --git a/.dockerignore b/.dockerignore index 792e93cda..fb1787aca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,6 @@ **/.next **/*.log .claude +secrets +**/secret.key +**/master.key diff --git a/.github/workflows/build-release-artifacts.yml b/.github/workflows/build-release-artifacts.yml new file mode 100644 index 000000000..c8bf3ede2 --- /dev/null +++ b/.github/workflows/build-release-artifacts.yml @@ -0,0 +1,214 @@ +name: Build release artifacts + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-release-artifacts-${{ github.ref }} + cancel-in-progress: false + +env: + BUN_VERSION: 1.3.11 + RUST_VERSION: 1.96.0 + +jobs: + notices: + name: Generate third-party notices + runs-on: ubuntu-22.04 + timeout-minutes: 20 + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + rustup override set "$RUST_VERSION" + + - name: Generate dependency license inventory + shell: bash + run: | + set -euo pipefail + cargo install cargo-about --version 0.9.0 --locked + cargo about generate about.hbs > THIRD_PARTY_LICENSES.html + + - name: Upload notices + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: executor-license-notices + path: | + THIRD_PARTY_LICENSES.html + if-no-files-found: error + compression-level: 9 + overwrite: true + retention-days: 14 + + build: + name: Build ${{ matrix.target }} + needs: notices + permissions: + contents: read + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + smoke: true + - runner: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + smoke: true + - runner: macos-15-intel + target: x86_64-apple-darwin + smoke: true + - runner: macos-15 + target: aarch64-apple-darwin + smoke: true + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install web dependencies + run: bun install --frozen-lockfile + + - name: Download third-party notices + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-license-notices + path: ${{ runner.temp }}/release-metadata + + - name: Build embedded web application + run: bun run --cwd web build + + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal --target "${{ matrix.target }}" + rustup override set "$RUST_VERSION" + + - name: Build release binary + run: cargo build --locked --release --target "${{ matrix.target }}" + + - name: Smoke test native binary + if: matrix.smoke + run: "target/${{ matrix.target }}/release/executor --help" + + - name: Package binary and checksum + id: package + shell: bash + run: | + set -euo pipefail + + artifact="executor-${{ matrix.target }}" + archive="${artifact}.tar.gz" + stage="$RUNNER_TEMP/$artifact" + output="$RUNNER_TEMP/release-artifacts" + + mkdir -p "$stage" "$output" + install -m 0755 "target/${{ matrix.target }}/release/executor" "$stage/executor" + install -m 0644 LICENSE "$stage/LICENSE" + install -m 0644 \ + "$RUNNER_TEMP/release-metadata/THIRD_PARTY_LICENSES.html" \ + "$stage/THIRD_PARTY_LICENSES.html" + install -m 0644 \ + web/build/THIRD_PARTY_JAVASCRIPT_LICENSES.json \ + "$stage/THIRD_PARTY_JAVASCRIPT_LICENSES.json" + tar -C "$stage" -czf "$output/$archive" \ + executor LICENSE THIRD_PARTY_LICENSES.html \ + THIRD_PARTY_JAVASCRIPT_LICENSES.json + + if command -v sha256sum >/dev/null 2>&1; then + (cd "$output" && sha256sum "$archive" > "$archive.sha256") + else + (cd "$output" && shasum -a 256 "$archive" > "$archive.sha256") + fi + + echo "artifact=$artifact" >> "$GITHUB_OUTPUT" + echo "output=$output" >> "$GITHUB_OUTPUT" + + - name: Attest release archive provenance + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: ${{ steps.package.outputs.output }}/${{ steps.package.outputs.artifact }}.tar.gz + + - name: Upload target artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.package.outputs.artifact }} + path: | + ${{ steps.package.outputs.output }}/${{ steps.package.outputs.artifact }}.tar.gz + ${{ steps.package.outputs.output }}/${{ steps.package.outputs.artifact }}.tar.gz.sha256 + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 14 + + checksums: + name: Assemble checksum manifest + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - name: Download target artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: release-artifacts + pattern: executor-*-*-* + merge-multiple: true + + - name: Verify archives and assemble manifest + shell: bash + working-directory: release-artifacts + run: | + set -euo pipefail + + expected_targets=( + aarch64-apple-darwin + aarch64-unknown-linux-gnu + x86_64-apple-darwin + x86_64-unknown-linux-gnu + ) + + : > SHA256SUMS + for target in "${expected_targets[@]}"; do + sidecar="executor-${target}.tar.gz.sha256" + test -f "executor-${target}.tar.gz" + test -f "$sidecar" + cat "$sidecar" >> SHA256SUMS + done + + sha256sum --check SHA256SUMS + + - name: Upload complete release bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: executor-release-artifacts + path: release-artifacts/ + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 937936e3b..93bd89797 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,18 +6,96 @@ on: branches: - main +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: + rust: + name: Rust + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + EXECUTOR_WEB_ASSETS_DIR: tests/fixtures/web-assets + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.96.0 \ + --profile minimal \ + --component clippy,rustfmt + rustup override set 1.96.0 + + - name: Cache Cargo + uses: Swatinem/rust-cache@401aff9a7a08acb9d27b64936a90db81024cff97 # v2.8.2 + with: + cache-on-failure: false + shared-key: rust-1.96.0 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Check all targets and features + run: cargo check --locked --all-targets --all-features + + - name: Lint all targets and features + run: cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Test all targets and features + run: cargo test --locked --all-targets --all-features + + web: + name: Svelte web + runs-on: ubuntu-22.04 + timeout-minutes: 20 + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check formatting + run: bun run format:check + working-directory: web + + - name: Lint + run: bun run lint + working-directory: web + + - name: Typecheck + run: bun run typecheck + working-directory: web + + - name: Test + run: bun run test + working-directory: web + format: name: Format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -29,9 +107,11 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -43,9 +123,11 @@ jobs: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -57,61 +139,77 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - # apps/cloud's test script invokes `node` directly; undici 8.x (pulled - # in by @cloudflare/vitest-pool-workers) calls webidl.markAsUncloneable - # which only exists in Node 22.10+. Pin a known-good runtime. - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - node-version: 22 - - - run: bun install --frozen-lockfile - - - run: bun run test + persist-credentials: false - desktop-smoke: - name: Desktop smoke build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 - run: bun install --frozen-lockfile - - name: Build web app - run: bun run --filter @executor-js/local build - - - name: Build bundled executor - env: - BUN_TARGET: bun-linux-x64 - run: bun ./scripts/build-sidecar.ts - working-directory: apps/desktop - - - name: Build Electron main/preload/renderer - run: bunx --bun electron-vite build - working-directory: apps/desktop + - run: bun run test selfhost-docker-smoke: - name: Self-host Docker image - runs-on: ubuntu-latest + name: Rust self-host Docker image + runs-on: ubuntu-22.04 + timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Build self-host image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . - file: apps/host-selfhost/Dockerfile + file: Dockerfile + load: true push: false tags: executor-selfhost:ci + + - name: Smoke test container health and shutdown + shell: bash + run: | + set -euo pipefail + docker volume create executor-ci-data >/dev/null + docker run --detach \ + --name executor-ci \ + --init \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --memory 2g \ + --pids-limit 512 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \ + --mount source=executor-ci-data,target=/var/lib/executor \ + executor-selfhost:ci >/dev/null + + status="starting" + for _ in {1..60}; do + status="$(docker inspect --format '{{.State.Health.Status}}' executor-ci)" + if [[ "$status" == "healthy" ]]; then + break + fi + if [[ "$(docker inspect --format '{{.State.Status}}' executor-ci)" == "exited" ]]; then + break + fi + sleep 1 + done + if [[ "$status" != "healthy" ]]; then + docker logs executor-ci + exit 1 + fi + + docker stop --time 120 executor-ci >/dev/null + test "$(docker inspect --format '{{.State.ExitCode}}' executor-ci)" = "0" + + - name: Clean up container smoke test + if: always() + run: | + docker rm --force executor-ci >/dev/null 2>&1 || true + docker volume rm --force executor-ci-data >/dev/null 2>&1 || true diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index cad7d4a30..06d62d857 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -1,10 +1,10 @@ name: Publish Desktop App run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}" -# Triggered manually or by publish-executor-package.yml after a CLI release -# lands. Builds Electron distributables for mac/win/linux with the compiled -# executor CLI bundled in `resources/executor/`, then attaches them to the GitHub -# release matching the tag so electron-updater can pick them up. +# Manually triggered for the archived Electron product. Builds macOS and Linux +# distributables with the compiled executor CLI bundled in +# `resources/executor/`, then attaches them to the matching GitHub release for +# legacy clients. on: workflow_dispatch: @@ -47,11 +47,6 @@ jobs: platform: linux bun-target: bun-linux-x64 smoke: true - - os: windows-latest - arch: x64 - platform: win - bun-target: bun-windows-x64 - smoke: true runs-on: ${{ matrix.os }} @@ -101,7 +96,7 @@ jobs: env: BUN_TARGET: ${{ matrix.bun-target }} run: bun ./scripts/build-sidecar.ts - working-directory: apps/desktop + working-directory: legacy/desktop # Gate the release on the compiled binary actually booting. v1.5.0/.1 # shipped local-server binaries that died on launch (missing libsql native @@ -110,7 +105,7 @@ jobs: - name: Smoke test bundled executor if: matrix.smoke run: bun run test:smoke - working-directory: apps/desktop + working-directory: legacy/desktop - name: Build Electron main/preload/renderer env: @@ -119,7 +114,7 @@ jobs: # is compiled out and dumps stay local. DESKTOP_SENTRY_DSN: ${{ vars.DESKTOP_SENTRY_DSN }} run: bunx --bun electron-vite build - working-directory: apps/desktop + working-directory: legacy/desktop - name: Stage Apple API key (mac signing + notarization) if: matrix.platform == 'mac' && env.APPLE_API_KEY != '' @@ -149,7 +144,7 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: bunx --bun electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never --config electron-builder.config.ts - working-directory: apps/desktop + working-directory: legacy/desktop # The two mac legs each emit a latest-mac.yml listing only their own # arch. Rename per-arch here; the release job merges them back into the @@ -159,20 +154,19 @@ jobs: - name: Rename mac update manifest per arch if: matrix.platform == 'mac' shell: bash - run: mv apps/desktop/dist/latest-mac.yml "apps/desktop/dist/latest-mac-${{ matrix.arch }}.yml" + run: mv legacy/desktop/dist/latest-mac.yml "legacy/desktop/dist/latest-mac-${{ matrix.arch }}.yml" - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: desktop-${{ matrix.platform }}-${{ matrix.arch }} path: | - apps/desktop/dist/*.dmg - apps/desktop/dist/*.zip - apps/desktop/dist/*.exe - apps/desktop/dist/*.AppImage - apps/desktop/dist/*.deb - apps/desktop/dist/*.rpm - apps/desktop/dist/latest*.yml + legacy/desktop/dist/*.dmg + legacy/desktop/dist/*.zip + legacy/desktop/dist/*.AppImage + legacy/desktop/dist/*.deb + legacy/desktop/dist/*.rpm + legacy/desktop/dist/latest*.yml if-no-files-found: warn release: @@ -221,7 +215,7 @@ jobs: echo "Uploading: $file" gh release upload "$RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" --clobber done < <(find artifacts -type f \ - \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" \ + \( -name "*.dmg" -o -name "*.zip" \ -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" \ -o -name "latest*.yml" \)) diff --git a/.github/workflows/publish-executor-package.yml b/.github/workflows/publish-executor-package.yml index 113ed9411..978744258 100644 --- a/.github/workflows/publish-executor-package.yml +++ b/.github/workflows/publish-executor-package.yml @@ -78,11 +78,6 @@ jobs: export GITHUB_REF="refs/tags/$RELEASE_TAG" bun run release:publish - - name: Trigger desktop build - env: - GH_TOKEN: ${{ github.token }} - run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" - - name: Trigger self-host Docker publish env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c51c05ea2..bbc0e7652 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,7 +131,3 @@ jobs: RELEASE_TAG: ${{ steps.validate_release.outputs.tag }} run: | gh workflow run publish-executor-package.yml --ref "$RELEASE_TAG" -f tag="$RELEASE_TAG" - - # Desktop build downloads CLI binaries from the release, so it must - # run after CLI publish completes. Trigger it from the CLI workflow - # or manually via: gh workflow run publish-desktop.yml -f tag=vX.Y.Z diff --git a/.gitignore b/.gitignore index ba8eb2cde..8c90e34fb 100644 --- a/.gitignore +++ b/.gitignore @@ -53,17 +53,17 @@ executor.har secret.key # desktop app build artifacts -apps/desktop/resources/ +legacy/desktop/resources/ # cloud local dev database .pglite -apps/cloud/.dev-db/ -apps/cloud/.e2e-db/ +legacy/cloud/.dev-db/ +legacy/cloud/.e2e-db/ # e2e suite: generated run artifacts + throwaway target state (the * also # covers ad-hoc variants like .e2e-stub-db-manual from debugging boots) e2e/runs/ -apps/cloud/.e2e-stub-db*/ +legacy/cloud/.e2e-stub-db*/ apps/host-selfhost/.e2e-data*/ # playwright e2e artifacts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d92df54ec..20ace484d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,6 +5,7 @@ ".reference", ".turbo", "dist", + "legacy", "vendor", "e2e/runs", "node_modules", @@ -12,7 +13,6 @@ "bun.lock", "*.tsbuildinfo", "executor-*.tgz", - "**/routeTree.gen.ts", - "apps/cloud/src/services/executor-schema.ts" + "**/routeTree.gen.ts" ] } diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 53839c4bb..c46c23d1c 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -60,9 +60,10 @@ { "files": [ "apps/cli/src/**/*.{ts,tsx}", - "apps/desktop/src/main.ts", + "legacy/desktop/src/main/**/*.{ts,tsx}", "scripts/**/*.{ts,js}", "apps/*/scripts/**/*.{ts,js}", + "legacy/*/scripts/**/*.{ts,js}", "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", ], "rules": { @@ -153,6 +154,7 @@ "vendor/", "emulators/", "e2e/runs/", + "legacy/", "node_modules/", "packages/core/fumadb/", "packages/core/sdk/src/vendor/json-schema-to-typescript/", diff --git a/.skills/cli-release/SKILL.md b/.skills/cli-release/SKILL.md index 11fb4234c..d68ab3deb 100644 --- a/.skills/cli-release/SKILL.md +++ b/.skills/cli-release/SKILL.md @@ -19,8 +19,8 @@ The CLI binary bundles: Does **not** ship in the CLI: -- `apps/cloud/**` (Cloudflare Workers deployment) -- `apps/marketing/**`, `apps/desktop/**` +- `legacy/cloud/**` (Cloudflare Workers deployment) +- `apps/marketing/**`, `legacy/desktop/**` - `examples/**`, `tests/**` **Implication for changelogs**: when asked "what changed since the last release", scope is `git log v..HEAD -- apps/cli apps/local packages`, not just `apps/cli`. Skipping `apps/local` and `packages` misses the bulk of product changes (Connections UI, OAuth plugins, SDK scope, OTEL, etc.). diff --git a/.skills/warden-security-review/SKILL.md b/.skills/warden-security-review/SKILL.md index 83555b865..4d127c241 100644 --- a/.skills/warden-security-review/SKILL.md +++ b/.skills/warden-security-review/SKILL.md @@ -46,8 +46,8 @@ Authz on cloud/API surfaces: ```bash npm exec --yes --package=@sentry/warden -- \ - warden "apps/cloud/src/auth/**/*.ts" "apps/cloud/src/api/**/*.ts" \ - "apps/cloud/src/routes/**/*.tsx" "packages/core/api/src/**/*.ts" \ + warden "legacy/cloud/src/auth/**/*.ts" "legacy/cloud/src/api/**/*.ts" \ + "legacy/cloud/src/routes/**/*.tsx" "packages/core/api/src/**/*.ts" \ --skill wrdn-authz --fail-on off --report-on low --min-confidence low \ --parallel 2 --log -o .warden-runs/authz.jsonl ``` @@ -69,7 +69,7 @@ npm exec --yes --package=@sentry/warden -- \ Data exfiltration on backend/API/storage/plugin SDK surfaces: ```bash -find apps/cloud/src/api apps/cloud/src/auth apps/local/src/server \ +find legacy/cloud/src/api legacy/cloud/src/auth apps/local/src/server \ packages/core/api/src packages/core/storage-core/src packages/core/storage-file/src \ packages/core/storage-postgres/src packages/core/storage-drizzle/src \ packages/plugins/mcp/src packages/plugins/openapi/src packages/plugins/graphql/src \ diff --git a/AGENTS.md b/AGENTS.md index 1c0dd1661..824bba8f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,8 +117,9 @@ before using it. - `packages/react`: shared React UI and atom/client integration. - `packages/hosts/mcp`: MCP host surface for exposing Executor through MCP. - `packages/kernel/*`: execution runtimes and code execution substrate. -- `apps/local`, `apps/cloud`, `apps/cli`, and `apps/desktop`: product entry - points that compose the packages. +- `src` and `web`: the active local/self-hosted Rust and Svelte product. +- `apps/local` and `apps/cli`: retained TypeScript compatibility entry points. +- `legacy/cloud` and `legacy/desktop`: archived deployment entry points. ## Other diff --git a/Cargo.toml b/Cargo.toml index a3bf125c7..403f1c099 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "executor" version = "0.1.0" edition = "2024" +license = "MIT" +build = "build.rs" [dependencies] anyhow = "1" @@ -17,7 +19,12 @@ hmac = "0.12" ipnet = "2.12.0" jsonschema = { version = "0.46.6", default-features = false } libc = "0.2" -oxc = { version = "=0.137.0", default-features = false, features = ["semantic", "transformer", "codegen", "ast_visit"] } +oxc = { version = "=0.137.0", default-features = false, features = [ + "semantic", + "transformer", + "codegen", + "ast_visit", +] } percent-encoding = "2" rand = "0.8.5" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } @@ -35,7 +42,18 @@ sqlx = { version = "0.8", default-features = false, features = [ ] } subtle = "2" thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "fs", "sync", "process", "io-util", "io-std"] } +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "net", + "signal", + "time", + "fs", + "sync", + "process", + "io-util", + "io-std", +] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" @@ -45,3 +63,7 @@ uuid = { version = "1", features = ["v4", "serde"] } http-body-util = "0.1" tempfile = "3" tower = { version = "0.5", features = ["util"] } + +[build-dependencies] +base64 = "0.22" +sha2 = "0.10" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..8e0ba47ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +# syntax=docker/dockerfile:1.7 + +FROM oven/bun:1.3.11 AS web-builder + +ARG TARGETARCH + +WORKDIR /build + +COPY package.json bun.lock ./ +COPY patches ./patches +COPY web/package.json ./web/package.json + +RUN --mount=type=cache,id=executor-bun-${TARGETARCH},target=/root/.bun/install/cache,sharing=locked \ + bun install --frozen-lockfile --ignore-scripts --filter @executor-js/web + +COPY web ./web +RUN bun run --cwd web build + + +FROM rust:1.96-bookworm AS rust-builder + +ARG TARGETARCH + +WORKDIR /build + +COPY Cargo.toml Cargo.lock build.rs LICENSE about.toml about.hbs ./ +COPY migrations ./migrations +COPY src ./src +COPY --from=web-builder /build/web/build ./web/build + +RUN --mount=type=cache,id=executor-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=executor-cargo-target-${TARGETARCH},target=/build/target,sharing=locked \ + cargo install cargo-about --version 0.9.0 --locked && \ + cargo about generate about.hbs > THIRD_PARTY_LICENSES.html && \ + cargo build --locked --release && \ + cp target/release/executor /usr/local/bin/executor + + +FROM debian:bookworm-slim AS runtime + +ARG EXECUTOR_UID=10001 +ARG EXECUTOR_GID=10001 + +RUN apt-get update && \ + apt-get install --yes --no-install-recommends ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd --gid "${EXECUTOR_GID}" executor && \ + useradd --uid "${EXECUTOR_UID}" --gid executor --no-create-home \ + --home-dir /var/lib/executor --shell /usr/sbin/nologin executor && \ + install --directory --owner executor --group executor --mode 0700 \ + /var/lib/executor /etc/executor + +COPY --from=rust-builder /usr/local/bin/executor /usr/local/bin/executor +COPY --from=rust-builder /build/LICENSE /usr/share/licenses/executor/LICENSE +COPY --from=rust-builder /build/THIRD_PARTY_LICENSES.html /usr/share/licenses/executor/THIRD_PARTY_LICENSES.html +COPY --from=rust-builder /build/web/build/THIRD_PARTY_JAVASCRIPT_LICENSES.json /usr/share/licenses/executor/THIRD_PARTY_JAVASCRIPT_LICENSES.json + +ENV EXECUTOR_DATA_DIR=/var/lib/executor \ + EXECUTOR_PUBLIC_ORIGIN=http://127.0.0.1:4788 \ + RUST_LOG=executor=info + +USER executor:executor +WORKDIR /var/lib/executor + +EXPOSE 4788 +VOLUME ["/var/lib/executor"] +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:4788/healthz"] + +ENTRYPOINT ["/usr/local/bin/executor"] +CMD ["server", "--bind", "0.0.0.0:4788", "--allow-unsafe-http-non-loopback"] diff --git a/Dockerfile.dockerignore b/Dockerfile.dockerignore new file mode 100644 index 000000000..9e947d812 --- /dev/null +++ b/Dockerfile.dockerignore @@ -0,0 +1,29 @@ +** + +!Dockerfile +!Cargo.toml +!Cargo.lock +!build.rs +!package.json +!bun.lock +!LICENSE +!about.toml +!about.hbs + +!migrations +!migrations/** +!patches +!patches/** +!src +!src/** +!web +!web/** + +web/build +web/.svelte-kit +web/node_modules +web/**/.env* +web/**/*.key +web/**/*.pem +web/**/*.db* +web/**/*secret* diff --git a/README.md b/README.md index 5eabe62c6..14962d62e 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,184 @@ -# executor +# Executor -[https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad](https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad) +Executor is a single-user, self-hosted tool gateway for AI agents. One Rust +binary serves the Svelte dashboard, stores state in SQLite, exposes an MCP +endpoint, and runs concurrent TypeScript tool workflows in isolated QuickJS +workers. -The integration layer for AI agents. One catalog for every tool, shared across every agent you use. +The active product supports: -[Ask DeepWiki](https://deepwiki.com/RhysSullivan/executor) +- OpenAPI, GraphQL, and MCP sources +- API key, bearer, basic, manual OAuth token, and managed OAuth credentials +- one global tool catalog with Enabled, Ask, and Disabled modes +- interactive approval for sensitive calls +- a stateful Streamable HTTP MCP endpoint and a local stdio bridge +- native Linux and macOS binaries, plus Docker -## Quick start +Windows is not a release target. The previous managed cloud and Electron +products are archived in [`legacy/`](legacy/README.md). -```bash -npm install -g executor -executor install -executor web -``` - -This installs the local background service and opens the web UI. From there, add your first source and start using tools. - -### Use as an MCP server +## Try it from this checkout -Point any MCP-compatible agent (Cursor, Claude Code, OpenCode, etc.) at Executor to share your tool catalog, auth, and policies across all of them. +The production binary embeds the dashboard, so the web build must run before +the release Cargo build. From the repository root: -```bash +```sh +bun run bootstrap +bun run --cwd web build +cargo build --locked --release -executor mcp +mkdir -p .executor-local/data +chmod 0700 .executor-local/data +./target/release/executor server --data-dir "$PWD/.executor-local/data" ``` -Example `mcp.json` for Claude Code / Cursor: +This checkout expects Bun 1.3.x and Rust 1.96 or newer. The release workflow +currently pins Bun 1.3.11 and Rust 1.96.0. -```json -{ - "mcpServers": { - "executor": { - "command": "executor", - "args": ["mcp"] - } - } -} -``` +Executor listens at `http://127.0.0.1:4788` by default. Open the one-time +`/setup#token=...` URL printed by the server and create the only administrator +account with a password of at least 12 characters. The dashboard then signs in +with those credentials. Open **API tokens** and create a token for your client. +The token secret is shown once. -### Use with Pi +On WSL2, paste the setup URL into the Windows browser. You can also open the +dashboard from the WSL shell after setup: -[Pi](https://pi.dev) does not include a built-in MCP client. To use Executor from Pi, install the community bridge: - -```bash -pi install git:github.com/gvkhosla/pi-executor-mcp@v0.2.0 +```sh +/mnt/c/windows/explorer.exe http://127.0.0.1:4788 ``` -Reload Pi, then verify the bridge: - -```text -/reload -/executor-status -``` - -After that, ask Pi to search, inspect, and call tools through Executor. - -## Add a source +State is under `.executor-local/data` in this example. Stop the server before +copying that directory for backup, and keep `executor.db`, its WAL files, and +`master.key` together. -If you can represent it with a JSON schema, it can be an integration. Executor has first-party support for OpenAPI, GraphQL, MCP, and Google Discovery — but the plugin system is open to any source type. - -### Via the web UI - -Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth. - -### Via the CLI - -```bash -executor call executor openapi addSource '{ - "spec": "https://petstore3.swagger.io/api/v3/openapi.json", - "namespace": "petstore", - "baseUrl": "https://petstore3.swagger.io/api/v3" -}' -``` +## Connect sources -Use `baseUrl` when the OpenAPI document has relative `servers` entries (for example `"/api/v3"`). +Open **Sources**, choose a connector, and follow the preview or connection +flow: -## Use tools +- OpenAPI accepts a URL or pasted OpenAPI 3.0/3.1 JSON or YAML. +- GraphQL connects to an introspection-enabled endpoint. +- MCP Streamable HTTP connects to a remote or local HTTP MCP endpoint. +- MCP stdio selects only a machine-admin-approved command template. -Agents discover and call tools through a typed TypeScript runtime: +After import, review each source under **Tools**. GraphQL queries start Enabled, +mutations start Ask, and deprecated operations start Disabled. MCP tools are +Enabled only when the upstream explicitly marks them read-only and not +destructive. Other MCP tools start Ask. -```ts -// discover by intent -const matches = await tools.discover({ query: "github issues", limit: 5 }); +See [source and OAuth setup](docs/sources.md) and the detailed +[MCP contract](docs/mcp.md). -// inspect the schema -const detail = await tools.describe.tool({ - path: matches.bestPath, - includeSchemas: true, -}); +## Use the CLI -// call with type safety -const issues = await tools.github.issues.list({ - owner: "vercel", - repo: "next.js", -}); -``` +Client commands talk to an already-running Executor server. They never open a +second copy of the database. -Use tools via the CLI: +```sh +export EXECUTOR_API_TOKEN='token-shown-by-the-dashboard' -```bash -executor tools search "send email" -executor call --help -executor call github --help -executor call github issues --help -executor call cloudflare --help --match dns --limit 20 -executor call github issues create '{"owner":"octocat","repo":"Hello-World","title":"Hi"}' -executor call gmail send '{"to":"alice@example.com","subject":"Hi"}' +./target/release/executor tools sources +./target/release/executor tools search 'create issue' +./target/release/executor tools describe source_slug.tool_name +./target/release/executor call source_slug.tool_name '{"input":"value"}' ``` -`executor call`, `executor resume`, and `executor tools ...` commands auto-start a local daemon if needed. -If the default port is busy, the CLI will pick an available local port and track it automatically. +Use `--base-url` or `EXECUTOR_BASE_URL` for another instance. Remote instances +must use HTTPS unless you deliberately pass `--allow-insecure-http` on a +separately authenticated and encrypted tunnel. A private LAN alone does not +protect the bearer token. See the [CLI guide](docs/cli.md). -If an execution pauses for auth or approval, resume it: +## Use Executor as an MCP server -```bash -executor resume --execution-id exec_123 -``` +For clients that support Streamable HTTP, use the instance origin plus `/mcp` +and send the dashboard API token as a bearer token. Client configuration +schemas differ, so treat this as a schematic shape and adapt it to the selected +client's MCP documentation: -## CLI reference - -```bash -executor install # install/start the durable background service -executor web # open the running web UI -executor web --foreground # start a temporary foreground runtime + web UI -executor daemon run # start persistent local daemon in background -executor daemon status # show daemon status -executor daemon stop # stop daemon -executor daemon restart # restart daemon -executor mcp # start MCP endpoint -executor call '{"k":"v"}' # invoke a tool by path segments -executor call --help # browse namespaces/resources/methods -executor call --help --match "" --limit # narrow huge namespaces -executor resume --execution-id # resume paused execution -executor tools search "" # search tools by intent -executor tools sources # list configured sources + tool counts -executor tools describe # show tool TypeScript/JSON schema +```json +{ + "mcpServers": { + "executor": { + "type": "http", + "url": "http://127.0.0.1:4788/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} ``` -## Developing locally +For a client that launches only stdio MCP servers, the common shape is: -```bash -bun install -bun dev +```json +{ + "mcpServers": { + "executor": { + "command": "/absolute/path/to/executor", + "args": ["mcp"], + "env": { + "EXECUTOR_API_TOKEN": "" + } + } + } +} ``` -The dev server starts at `http://127.0.0.1:4788`. +The bridge connects to `http://127.0.0.1:4788` by default. Set +`EXECUTOR_BASE_URL` when the server uses another origin. -### Tests +## Install and operate -```bash -bun run test # unit + integration suites -bun run test:e2e # full-stack e2e: boots the cloud and self-host apps and drives them -``` - -The browser e2e scenarios need Playwright's Chromium once per machine: -`bunx playwright install chromium`. - -## Community +- [Native install and first boot](docs/install.md) +- [Docker Compose](docs/docker.md) +- [Linux systemd](docs/systemd.md) +- [macOS launchd](docs/launchd.md) +- [Runtime and sandbox boundary](docs/runtime.md) +- [Architecture and security contracts](docs/architecture.md) -Join the Discord: [https://discord.gg/eF29HBHwM6](https://discord.gg/eF29HBHwM6) +For a reverse proxy or managed OAuth, set `EXECUTOR_PUBLIC_ORIGIN` to the exact +HTTPS origin used in the browser before starting Executor. Callback URLs are +connection-specific and are displayed in the source's Managed OAuth panel. -## Learn more +## Develop and verify -Visit [executor.sh](https://executor.sh) to learn more. +`bun run bootstrap` installs workspace dependencies, prepares the retained +TypeScript packages, and installs Playwright Chromium. -## Attribution +A debug Rust server does not require production web assets: -- Thank you to [Crystian](https://www.linkedin.com/in/crystian/) for providing the npm package name `executor`. +```sh +mkdir -p .executor-debug +chmod 0700 .executor-debug +cargo run -- server --data-dir "$PWD/.executor-debug" +``` -## References +Debug builds intentionally embed a small fixture page. That path is suitable +for API, CLI, and MCP work, not dashboard acceptance. Use the release build +sequence above when you need the real embedded Svelte application. + +The broad merge gates are: + +```sh +bun run format:check +bun run lint +bun run typecheck +bun run test +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +bun run test:e2e +``` -As part of my coding process, I give my agent access to references to other codebases to understand patterns and how other people have implemented systems. +The e2e command builds the Svelte assets and a local debug Rust binary before +driving the real first-boot, source, tool-mode, approval, log, token, and OAuth +journeys in Chromium. -A non exhaustive list of references are: +See [`RUNNING.md`](RUNNING.md) for the current repository workflow and e2e +status. Default commands exclude the archived cloud and desktop products. -- [Better Auth](https://github.com/better-auth/better-auth) - Storage adapter reference -- [Effect](https://github.com/Effect-TS/effect) - General code patterns -- [OpenCode](https://github.com/anomalyco/opencode) - Plugin system reference -- [OpenClaw](https://github.com/openclaw/openclaw) - Plugin system reference -- [Emdash](https://github.com/emdash-cms/emdash) - Plugin system reference -- [Pi](https://github.com/badlogic/pi-mono) - Plugin system reference +## License -It's encouraged also that you can use this codebase as a reference to understand how it's implemented +MIT diff --git a/RELEASING.md b/RELEASING.md index 7285fa719..f808650f1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,13 +22,15 @@ the self-host Docker image. - performs a full dry-run release build before publish - publishes the CLI npm package under the correct dist-tag - creates or updates the GitHub release with build artifacts - - dispatches `.github/workflows/publish-desktop.yml` - dispatches `.github/workflows/publish-selfhost-docker.yml` 6. The self-host Docker workflow publishes `ghcr.io/rhyssullivan/executor-selfhost` for `linux/amd64` and `linux/arm64`: - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest` - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta` +The archived Electron workflow is manual-only and is not part of the active +release path. + ## Beta releases Enter prerelease mode before starting a beta train: diff --git a/RUNNING.md b/RUNNING.md index fac336a7f..818933a91 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,148 +1,186 @@ -# RUNNING.md — how things run today - -> **This document may be out of date.** It describes how things run today, -> not how they must run. Trust it as a starting point; if you hit weirdness, -> the implementation has probably moved and this file is why. Verify against -> the code, then update this file when you notice drift. The principles in -> [AGENTS.md](AGENTS.md) are the stable contract; everything below is -> implementation detail that churns. - -## Fresh checkout / worktree setup - -`bun run bootstrap` from the repo root — idempotent: `bun install` (whose -prepare hook builds `@executor-js/vite-plugin` and `packages/react`, the -artifacts dev servers fail without) plus Playwright chromium. A fresh -worktree that skips it dies with "Failed to resolve entry for package -'@executor-js/vite-plugin'". - -Our two upstream forks — `@executor-js/emulate` (service emulators) and -`@executor-js/mcporter` (headless MCP client) — are consumed purely as -published npm packages; nothing in this repo references them by path. There -are no `vendor/` submodules. Each fork is its own standalone repo -(`github.com/UsefulSoftwareCo/emulate`, `github.com/UsefulSoftwareCo/mcporter`): -develop on its `main`, publish a bump, then bump the dependency here. The -`emulate` skill covers the emulator publish/deploy loop. - -## Dev servers - -- Everything except desktop/cloud: `bun run dev` (turbo, from root) -- One app: `bun run dev` from its `apps/` directory -- Self-host boots standalone with just env vars — see - `e2e/setup/selfhost.globalsetup.ts` for the canonical recipe (data dir, - bootstrap admin email/password, base URL, `EXECUTOR_ALLOW_LOCAL_NETWORK`) -- Cloud needs WorkOS + Autumn; for a no-.env boot, point it at emulators — - see `e2e/setup/cloud.globalsetup.ts` for the canonical recipe (the real - SDKs against emulated services, PGlite dev DB) - -The e2e globalsetup files are the source of truth for "how do I boot a -working instance of X" — read them before inventing a boot path. +# Running the Rust and Svelte rewrite + +This file describes the active local and self-hosted product. The archived +cloud and Electron entry points are under `legacy/` and are not part of the +default workflow. + +## Fresh checkout + +From the repository root: + +```sh +bun run bootstrap +``` + +Bootstrap runs the workspace install and prepare hooks, then installs +Playwright Chromium. It is safe to rerun. The workspace currently declares Bun +1.3.11 and the native release workflow uses Rust 1.96.0. + +## Production-like local run + +The real Svelte dashboard is compiled first and embedded in the Rust release +binary: + +```sh +bun run --cwd web build +cargo build --locked --release + +mkdir -p .executor-local/data +chmod 0700 .executor-local/data +./target/release/executor server --data-dir "$PWD/.executor-local/data" +``` + +Open the setup URL printed by the process. The dashboard creates the +administrator and immediately signs in with those credentials. Add a source, +review its tool modes, and create an API token under `/tokens`. + +The server binds to `127.0.0.1:4788` unless `--bind` changes it. Keep plaintext +HTTP on loopback. For another browser-facing hostname, terminate TLS at a +reverse proxy and set the exact external origin with `--public-origin` or +`EXECUTOR_PUBLIC_ORIGIN`. + +On this WSL2 machine, the Windows browser can reach the loopback server. Open +it with: + +```sh +/mnt/c/windows/explorer.exe http://127.0.0.1:4788 +``` + +Use a worktree-specific data directory, as shown above, so concurrent checkouts +never share SQLite or the instance master key. One process lock protects each +data directory. + +## Debug server without a web build + +Normal debug compilation does not require `web/build`: + +```sh +mkdir -p .executor-debug +chmod 0700 .executor-debug +cargo run -- server --data-dir "$PWD/.executor-debug" +``` + +Debug builds embed the deterministic fixture under `tests/fixtures/web-assets`. +This is useful for Rust API, CLI, MCP, and lifecycle work. It is not a visual +dashboard development server. Use the production-like sequence when the real +Svelte application must be present. + +`EXECUTOR_WEB_ASSETS_DIR` is a compile-time packaging override for focused +asset tests. It is not a runtime static-directory setting. + +## Client smoke test + +Create a dashboard API token, then use the same binary as a client: + +```sh +export EXECUTOR_API_TOKEN='token-shown-once-by-the-dashboard' + +./target/release/executor tools sources +./target/release/executor tools search 'health check' +./target/release/executor tools describe source_slug.tool_name +./target/release/executor call source_slug.tool_name '{}' +``` + +The server never auto-starts for client commands. `call`, `tools`, and `mcp` +require a running server and an API token. `open` only opens the clean dashboard +URL and uses the administrator login cookie in the browser. ## Managed OAuth callbacks -Managed OAuth callback URLs are built from the server's public origin. A -loopback server derives that origin from its actual bind address. When users -reach Executor through HTTPS, a reverse proxy, or a tailnet hostname, start the -server with the exact browser-facing origin: +Set the final browser-facing origin before creating an OAuth connection: ```sh -executor server --public-origin https://executor.example.com +./target/release/executor server \ + --data-dir "$PWD/.executor-local/data" \ + --public-origin https://executor.example.com +``` + +After importing an eligible OpenAPI, GraphQL, or HTTP MCP source, open its +**Managed OAuth** panel. Save the provider discovery and client configuration, +copy the exact displayed callback URL into the provider, then select **Connect +OAuth**. Every connection has its own callback path: + +```text +/api/v1/oauth/callback/ ``` -`EXECUTOR_PUBLIC_ORIGIN` is the environment-variable equivalent. The value is -an origin only, with no path, query, fragment, or credentials. It also controls -setup links and the dashboard's host and origin checks, so it must match the -URL used in the browser. - -After adding a source, open its Managed OAuth panel and save the provider's -issuer or MCP discovery settings plus the OAuth client. Executor then displays -the exact connection-specific callback URL. Register that exact URL with the -provider before selecting **Connect OAuth**. Callback paths have the form -`/api/v1/oauth/callback/` and must not be replaced with one -shared path. - -## E2E: running, viewing, sharing - -`e2e/AGENTS.md` covers writing scenarios. Operationally: - -- `cd e2e && bun run test` boots dev servers and runs everything; - `--project cloud|selfhost` narrows. `E2E_CLOUD_URL`/`E2E_SELFHOST_URL` - attach to an already-running server instead of booting. -- Runs land in `e2e/runs///` — `result.json`, step - screenshots, `session.mp4` + `trace.zip` for browser scenarios, and the - scenario source as `test.ts`. -- `cd e2e && bun run serve` builds the viewer and serves the scenario × - target matrix over HTTP, bound to all interfaces (reachable over the - tailnet). It prefers port 8901 but walks forward to the next free port if - that's taken (so concurrent worktrees, or a leaked previous viewer, never - wedge each other) — read the printed `e2e viewer → …` URL for the actual - port. `PORT=…` pins a port explicitly and fails loudly if it's busy. The - built SPA is port- and mount-agnostic (relative assets + hash routing), so - whatever port it lands on just works. Individual runs are at - `#//` hash routes — when handing results to the user, link - those directly, not the bare matrix. -- `bun e2e/scripts/pr-media.ts e2e/runs//` converts a run's - recording to a gif, uploads it to the `e2e-media` branch, and prints - PR-ready markdown. - -E2E dev-server ports are derived and CLAIMED per checkout (`cd e2e && bun -run ports` prints this checkout's block; see `e2e/src/ports.ts`) — each -checkout hashes its repo root to a preferred block, atomically locks it, -and walks to the next free block if squatted, so concurrent worktrees never -collide or attach to each other's servers. `E2E_*_PORT` env vars pin ports -explicitly. If a boot reports a squatted port, an old dev server leaked — -`bun run reap` (repo root) lists and kills orphaned stacks. - -## The dev CLI: live instances, interactively - -`cd e2e && bun run cli` — the same primitives scenarios use, as commands. -Boot a target, mint identities, make typed API calls, drive MCP, read the -emulator ledger — develop interactively, then crystallize the journey into -a scenario. +The supported managed flow is OAuth 2 authorization code with PKCE. OpenID +Connect and the `openid` scope are not supported. See +[`docs/sources.md`](docs/sources.md). + +## Verification + +Use the narrowest relevant command while iterating. For a merge-ready change, +run the complete active-product gates: + +```sh +bun run format:check +bun run lint +bun run typecheck +bun run test + +bun run --cwd web format:check +bun run --cwd web lint +bun run --cwd web check +bun run --cwd web test + +cargo fmt --check +cargo check --all-targets --all-features +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +``` + +Use `vitest run ...` or a package script that delegates to Vitest for focused +TypeScript tests. Never use `bun test`. + +## Browser e2e + +`bun run test:e2e` builds the Svelte distribution, embeds it in the debug Rust +binary, and runs only the active `local-selfhost` browser project. The harness +boots two fresh loopback instances: one prepared single-admin instance for the +main journeys and one untouched instance for the first-boot setup journey. +Both use temporary private data directories that are removed during teardown. + +The throwaway main-instance credentials are: + +```text +username: admin +password: executor-e2e-admin-password +``` + +The archived cloud browser suite remains an explicit opt-in: + +```sh +bun run legacy:test:e2e:cloud +``` + +Runs land under `e2e/runs/local-selfhost//`. View them with +`cd e2e && bun run serve`, then open the exact URL and port printed by the +viewer. Credential-entry journeys deliberately omit traces and video so setup +secrets, passwords, and one-time API tokens never become artifacts. + +## Service and container runs + +Do not invent service paths or flags from this file. Use the maintained +operator guides: + +- [`docs/docker.md`](docs/docker.md) +- [`docs/systemd.md`](docs/systemd.md) +- [`docs/launchd.md`](docs/launchd.md) +- [`docs/install.md`](docs/install.md) + +## Legacy opt-ins + +Default dev, test, lint, typecheck, and format paths exclude the archived cloud +and desktop products. Work on them only through the explicit scripts: ```sh -bun run cli up selfhost --share # boot, reachable over the tailnet, stays up -bun run cli up cloud --share # emulated WorkOS+Autumn, tailscale-HTTPS fronted -bun run cli status # what's running, URLs, creds -bun run cli identity selfhost # fresh identity (headers / cookies / creds) -bun run cli api selfhost tools.list -bun run cli mcp selfhost call execute '{"code":"return 1+1;"}' -bun run cli ledger cloud workos # what hit the emulator -bun run cli down selfhost # tear down (also removes tailscale serves) +bun run legacy:dev +bun run legacy:test +bun run legacy:typecheck +bun run legacy:typecheck:slow +bun run legacy:test:e2e:cloud +bun run legacy:test:e2e:desktop ``` -Instances persist until `down` — `up --share` IS the "touch it" handoff -artifact, and the seeding direction too: boot, drive the product into a -state (API/MCP/UI), hand across the URL. State files in `e2e/.dev/` mark -deliberate long-lived instances (vs leaks); attach scenarios to a running -instance with `E2E__URL`. - -Why cloud `--share` is more involved (encoded in the CLI, kept here for -when you hit it manually): the cloud app sets `secure: true` auth cookies, -so login breaks over plain http from any non-localhost origin ("Invalid -login state"). Both the app AND the WorkOS emulator get fronted with -`tailscale serve` HTTPS, the emulator advertises its public URL on both -sides (its `baseUrl` and the app's `WORKOS_API_URL` — the browser-facing -authorize URL derives from the latter), and Vite must allow the public -hostname (`__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`). - -## Environment gotchas (learned the hard way) - -- The shell is fish, and the working directory resets between Bash calls. - Use absolute paths rooted at THIS worktree; don't rely on a prior `cd`. -- Don't write probe scripts to `/tmp` — they can't resolve workspace - packages (`effect`, `playwright`, …). Put scratch scripts under the repo - root (`scratch/` is gitignored) so bun resolves the workspace. -- A fresh worktree's Vite dep-optimizer cache can serve PRE-REBASE code - (symptom: behavior matching old code only in dev servers, while unit - tests pass). Kill the server, clear `node_modules/.vite` / - `.tanstack`-adjacent caches, reboot. -- The real Tailscale CLI on this machine is - `/opt/homebrew/opt/tailscale/bin/tailscale`; `/usr/local/bin/tailscale` - is a broken shim pointing at a deleted app. The tailnet IP is on the - `utun` interface (100.x.y.z) if the CLI fails. -- `bun.lock` conflicts on rebase: take either side, re-run `bun install`, - never hand-merge. -- Long-lived demo servers you left up for the user look like leaks to - cleanup tooling — `e2e/.dev/.json` marks deliberate instances; - check it before reaping, and `bun run cli down ` is the clean - teardown. +See [`legacy/README.md`](legacy/README.md) for the boundary. diff --git a/about.hbs b/about.hbs new file mode 100644 index 000000000..31f8092d2 --- /dev/null +++ b/about.hbs @@ -0,0 +1,27 @@ + + + + + Executor third-party licenses + + +

Executor third-party licenses

+
    + {{#each overview}} +
  • {{name}} ({{count}})
  • + {{/each}} +
+ {{#each licenses}} +
+

{{name}}

+

Used by:

+
    + {{#each used_by}} +
  • {{crate.name}} {{crate.version}}
  • + {{/each}} +
+
{{text}}
+
+ {{/each}} + + \ No newline at end of file diff --git a/about.toml b/about.toml new file mode 100644 index 000000000..a50618dc2 --- /dev/null +++ b/about.toml @@ -0,0 +1,32 @@ +accepted = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "OpenSSL", + "Unicode-3.0", + "Unicode-DFS-2016", + "Unlicense", + "Zlib", +] + +targets = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", +] + +ignore-build-dependencies = false +ignore-dev-dependencies = true +filter-noassertion = true +no-clearly-defined = true +workarounds = ["ring", "rustls"] diff --git a/apps/docs/README.md b/apps/docs/README.md index 1999936d8..66f2b0801 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -20,7 +20,7 @@ server hot-reloads. Mintlify hosts the built site at `executor.mintlify.dev`. The Executor Cloud worker reverse-proxies it onto the first-party origin at `executor.sh/docs` -(see `apps/cloud/src/edge/docs.ts`), so the public docs live at +(see `legacy/cloud/src/edge/docs.ts`), so the public docs live at `executor.sh/docs` instead of a `*.mintlify.dev` subdomain. Mintlify is configured to host under the `/docs` subpath (Settings → Domain diff --git a/apps/host-selfhost/src/db/self-host-db.ts b/apps/host-selfhost/src/db/self-host-db.ts index fde91f89b..8737162eb 100644 --- a/apps/host-selfhost/src/db/self-host-db.ts +++ b/apps/host-selfhost/src/db/self-host-db.ts @@ -23,7 +23,7 @@ import { SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; // --------------------------------------------------------------------------- // SQLite executor DB factory, inline (like apps/local's sqlite-fumadb.ts and -// apps/cloud's fuma.ts — each app owns its DB wiring; there is no shared +// legacy/cloud's fuma.ts, each app owns its DB wiring; there is no shared // storage package). Differences from apps/local: busy_timeout + synchronous // pragmas for the multi-user HTTP server, and the idempotent // `ensureDrizzleRuntimeSchemaFromTables` schema-ensure (the drizzle adapter diff --git a/apps/host-selfhost/src/plugins.ts b/apps/host-selfhost/src/plugins.ts index bbd4c65ef..ea83f6550 100644 --- a/apps/host-selfhost/src/plugins.ts +++ b/apps/host-selfhost/src/plugins.ts @@ -1,5 +1,5 @@ // Single shared instantiation of the self-host plugin list, mirroring -// `apps/cloud/src/api/cloud-plugins.ts`. The API composition +// `legacy/cloud/src/api/cloud-plugins.ts`. The API composition // (`composePluginApi`/`composePluginHandlerLayer`) and the per-request // middleware (`providePluginExtensions`, `PluginExtensionServices<...>`) all // derive their typed views from this one tuple, so adding/removing a plugin is diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 877af5b81..aa31603ba 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -9,7 +9,7 @@ import react from "@astrojs/react"; import cloudflare from "@astrojs/cloudflare"; // Single source of truth for public build-time vars: wrangler.toml `[vars]`. -// Mirrors apps/cloud, which reads its wrangler.jsonc vars the same way. The +// Mirrors legacy/cloud, which reads its wrangler.jsonc vars the same way. The // PUBLIC_ ones are inlined into the client bundle via Vite `define`, so the // browser PostHog SDK gets the key at build time; they also remain runtime // Worker bindings. diff --git a/apps/marketing/wrangler.toml b/apps/marketing/wrangler.toml index ace9027ce..03fbb06b6 100644 --- a/apps/marketing/wrangler.toml +++ b/apps/marketing/wrangler.toml @@ -4,7 +4,7 @@ compatibility_flags = ["nodejs_compat"] # Public build-time vars, the single source of truth. astro.config.mjs reads the # PUBLIC_ ones from here and inlines them into the client bundle (same approach -# apps/cloud uses with its wrangler.jsonc vars). phc_ is a public PostHog project +# legacy/cloud uses with its wrangler.jsonc vars). phc_ is a public PostHog project # key, shipped in client JS by design, so it belongs in committed config. [vars] PUBLIC_POSTHOG_KEY = "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL" diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..12f583c5a --- /dev/null +++ b/build.rs @@ -0,0 +1,299 @@ +use std::{ + collections::{BTreeSet, HashSet}, + env, fs, + path::{Component, Path, PathBuf}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use sha2::{Digest, Sha256}; + +const WEB_DISTRIBUTION_DIRECTORY: &str = "web/build"; +const DEVELOPMENT_FIXTURE_DIRECTORY: &str = "tests/fixtures/web-assets"; +const MAX_ASSET_COUNT: usize = 4_096; +const MAX_ASSET_BYTES: u64 = 16 * 1024 * 1024; +const MAX_TOTAL_ASSET_BYTES: u64 = 128 * 1024 * 1024; +const MAX_INLINE_SCRIPT_HASHES: usize = 256; + +fn main() { + println!("cargo:rerun-if-changed={WEB_DISTRIBUTION_DIRECTORY}"); + println!("cargo:rerun-if-changed={DEVELOPMENT_FIXTURE_DIRECTORY}"); + println!("cargo:rerun-if-env-changed=PROFILE"); + println!("cargo:rerun-if-env-changed=EXECUTOR_WEB_ASSETS_DIR"); + + let profile = env::var("PROFILE").unwrap_or_default(); + let override_directory = env::var_os("EXECUTOR_WEB_ASSETS_DIR").map(PathBuf::from); + let production_assets = profile != "debug" || override_directory.is_some(); + let source = if let Some(source) = override_directory.as_deref() { + println!("cargo:rerun-if-changed={}", source.display()); + source + } else if production_assets { + let source = Path::new(WEB_DISTRIBUTION_DIRECTORY); + if !source.join("index.html").is_file() { + panic!( + "production packaging requires {WEB_DISTRIBUTION_DIRECTORY}/index.html; run the web asset packaging step before compiling the production binary" + ); + } + source + } else { + Path::new(DEVELOPMENT_FIXTURE_DIRECTORY) + }; + + let output = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo provides OUT_DIR")); + let copied_assets = output.join("embedded-web-assets"); + let _ = fs::remove_dir_all(&copied_assets); + fs::create_dir_all(&copied_assets).expect("embedded web asset output directory is created"); + + let mut relative_paths = Vec::new(); + collect_files(source, source, &mut relative_paths); + relative_paths.sort(); + if relative_paths.len() > MAX_ASSET_COUNT { + panic!( + "web asset distribution contains {} files; the maximum is {MAX_ASSET_COUNT}", + relative_paths.len() + ); + } + if !relative_paths.iter().any(|path| path == "index.html") { + panic!("embedded web assets must include index.html"); + } + let mut normalized_paths = HashSet::with_capacity(relative_paths.len()); + for path in &relative_paths { + if !normalized_paths.insert(path.to_ascii_lowercase()) { + panic!("web assets contain duplicate case-insensitive paths: {path}"); + } + } + if production_assets + && !relative_paths.iter().any(|path| { + path.starts_with("_app/immutable/") + && matches!( + Path::new(path).extension().and_then(|value| value.to_str()), + Some("js" | "mjs" | "css") + ) + }) + { + panic!( + "release web assets must contain at least one JavaScript or CSS file below _app/immutable" + ); + } + + let mut generated = format!( + "static EMBEDDED_WEB_ASSETS: [EmbeddedAsset; {}] = [\n", + relative_paths.len() + ); + let mut total_bytes = 0_u64; + let mut content_security_policy = None; + for relative_path in relative_paths { + let asset_path = source.join(&relative_path); + let content = fs::read(&asset_path) + .unwrap_or_else(|error| panic!("could not read {}: {error}", asset_path.display())); + let asset_bytes = content.len() as u64; + if asset_bytes > MAX_ASSET_BYTES { + panic!( + "web asset {relative_path} is {asset_bytes} bytes; the per-file maximum is {MAX_ASSET_BYTES}" + ); + } + total_bytes = total_bytes + .checked_add(asset_bytes) + .filter(|bytes| *bytes <= MAX_TOTAL_ASSET_BYTES) + .unwrap_or_else(|| { + panic!( + "web asset distribution exceeds the {MAX_TOTAL_ASSET_BYTES}-byte aggregate maximum" + ) + }); + let destination = copied_assets.join(&relative_path); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).expect("embedded web asset parent directory is created"); + } + fs::write(&destination, &content).expect("web asset is copied into Cargo output"); + if relative_path == "index.html" { + content_security_policy = Some(index_content_security_policy(&content)); + } + let etag = format!("\"{:x}\"", Sha256::digest(&content)); + generated.push_str(" EmbeddedAsset { path: "); + generated.push_str(&format!("{relative_path:?}")); + generated.push_str(", content: include_bytes!(concat!(env!(\"OUT_DIR\"), "); + generated.push_str(&format!( + "{:?}", + format!("/embedded-web-assets/{relative_path}") + )); + generated.push_str(")), etag: "); + generated.push_str(&format!("{etag:?}")); + generated.push_str(" },\n"); + } + generated.push_str("];\n"); + let content_security_policy = + content_security_policy.expect("embedded web assets include index.html"); + let generated = + format!("static EMBEDDED_WEB_CSP: &str = {content_security_policy:?};\n{generated}"); + fs::write(output.join("embedded_web_assets.rs"), generated) + .expect("embedded web asset manifest is written"); +} + +fn index_content_security_policy(index: &[u8]) -> String { + let hashes = inline_script_hashes(index); + let mut script_source = String::from("script-src 'self'"); + for hash in hashes { + script_source.push_str(" 'sha256-"); + script_source.push_str(&hash); + script_source.push('\''); + } + format!( + "default-src 'none'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; manifest-src 'self'; object-src 'none'; {script_source}; style-src 'self' 'unsafe-inline'; worker-src 'self'" + ) +} + +fn inline_script_hashes(index: &[u8]) -> BTreeSet { + const OPEN: &[u8] = b" break, + (None, Some(_)) => panic!("index.html contains a closing script tag without an opener"), + (Some(open), Some(close)) if close < open => { + panic!("index.html contains a closing script tag without an opener") + } + (Some(open), _) => { + let open_end = index[open + OPEN.len()..] + .iter() + .position(|byte| *byte == b'>') + .map(|offset| open + OPEN.len() + offset) + .unwrap_or_else(|| panic!("index.html contains an unclosed script opener")); + let body_start = open_end + 1; + let close = find_ascii_tag(index, CLOSE, body_start) + .unwrap_or_else(|| panic!("index.html contains an unclosed script body")); + let close_end = index[close + CLOSE.len()..] + .iter() + .position(|byte| *byte == b'>') + .map(|offset| close + CLOSE.len() + offset) + .unwrap_or_else(|| panic!("index.html contains an unclosed script closer")); + if !index[close + CLOSE.len()..close_end] + .iter() + .all(|byte| byte.is_ascii_whitespace()) + { + panic!("index.html contains a malformed closing script tag"); + } + + hashes.insert(STANDARD.encode(Sha256::digest(&index[body_start..close]))); + if hashes.len() > MAX_INLINE_SCRIPT_HASHES { + panic!( + "index.html contains more than {MAX_INLINE_SCRIPT_HASHES} distinct script bodies" + ); + } + cursor = close_end + 1; + } + } + } + hashes +} + +fn find_ascii_tag(input: &[u8], tag: &[u8], start: usize) -> Option { + if tag.len() > input.len() { + return None; + } + (start..=input.len() - tag.len()).find(|position| { + input[*position..*position + tag.len()].eq_ignore_ascii_case(tag) + && input + .get(*position + tag.len()) + .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(*byte, b'>' | b'/')) + }) +} + +fn collect_files(root: &Path, directory: &Path, files: &mut Vec) { + let mut entries = fs::read_dir(directory) + .unwrap_or_else(|error| panic!("could not read {}: {error}", directory.display())) + .collect::, _>>() + .unwrap_or_else(|error| panic!("could not enumerate {}: {error}", directory.display())); + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let file_type = entry.file_type().unwrap_or_else(|error| { + panic!("could not inspect {}: {error}", entry.path().display()) + }); + if file_type.is_symlink() { + panic!( + "web assets cannot contain symbolic links: {}", + entry.path().display() + ); + } + if file_type.is_dir() { + collect_files(root, &entry.path(), files); + } else if file_type.is_file() { + let entry_path = entry.path(); + let relative_path = entry_path + .strip_prefix(root) + .expect("asset remains below its root"); + validate_asset_path(relative_path); + files.push( + relative_path + .to_str() + .unwrap_or_else(|| { + panic!("web asset path is not UTF-8: {}", entry_path.display()) + }) + .replace('\\', "/"), + ); + } + } +} + +fn validate_asset_path(path: &Path) { + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + panic!( + "web asset path contains an unsafe component: {}", + path.display() + ); + } + let component = component + .as_os_str() + .to_str() + .unwrap_or_else(|| panic!("web asset path is not UTF-8: {}", path.display())); + if component.starts_with('.') + || component.contains('\\') + || component.contains('%') + || component.chars().any(char::is_control) + { + panic!( + "web asset path contains a hidden or unsafe component: {}", + path.display() + ); + } + } + + let extension = path.extension().and_then(|extension| extension.to_str()); + let allowed = matches!( + extension, + Some( + "html" + | "css" + | "js" + | "mjs" + | "json" + | "svg" + | "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "avif" + | "ico" + | "woff" + | "woff2" + | "ttf" + | "otf" + | "txt" + | "xml" + | "webmanifest" + | "wasm" + ) + ); + if !allowed { + panic!( + "web asset has an unsupported extension (source maps and secret/config files are not embedded): {}", + path.display() + ); + } +} diff --git a/bun.lock b/bun.lock index c565064c9..5f551f35d 100644 --- a/bun.lock +++ b/bun.lock @@ -11,13 +11,13 @@ "@effect/tsgo": "^0.5.2", "@effect/vitest": "catalog:", "@typescript/native-preview": "^7.0.0-dev.20260410.1", - "@vitest/expect": "catalog:", - "@vitest/mocker": "catalog:", - "@vitest/pretty-format": "catalog:", - "@vitest/runner": "catalog:", - "@vitest/snapshot": "catalog:", - "@vitest/spy": "catalog:", - "@vitest/utils": "catalog:", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "atmn": "^1.1.8", "effect": "catalog:", "knip": "^6.3.0", @@ -25,7 +25,7 @@ "oxlint": "^1.56.0", "turbo": "^2.5.6", "typescript": "^5.9.3", - "vitest": "catalog:", + "vitest": "4.1.9", }, }, "apps/cli": { @@ -53,117 +53,6 @@ "vitest": "catalog:", }, }, - "apps/cloud": { - "name": "@executor-js/cloud", - "version": "1.4.36", - "dependencies": { - "@cloudflare/vite-plugin": "^1.31.1", - "@effect/atom-react": "catalog:", - "@effect/opentelemetry": "catalog:", - "@executor-js/api": "workspace:*", - "@executor-js/cloudflare": "workspace:*", - "@executor-js/execution": "workspace:*", - "@executor-js/fumadb": "workspace:*", - "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-google": "workspace:*", - "@executor-js/plugin-graphql": "workspace:*", - "@executor-js/plugin-mcp": "workspace:*", - "@executor-js/plugin-microsoft": "workspace:*", - "@executor-js/plugin-openapi": "workspace:*", - "@executor-js/plugin-workos-vault": "workspace:*", - "@executor-js/react": "workspace:*", - "@executor-js/runtime-dynamic-worker": "workspace:*", - "@executor-js/runtime-quickjs": "workspace:*", - "@executor-js/sdk": "workspace:*", - "@executor-js/vite-plugin": "workspace:*", - "@modelcontextprotocol/sdk": "^1.29.0", - "@opentelemetry/api": "~1.9.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-logs": "^0.214.0", - "@opentelemetry/sdk-trace-base": "^2.6.1", - "@opentelemetry/sdk-trace-web": "^2.6.1", - "@opentelemetry/semantic-conventions": "^1.40.0", - "@sentry/cloudflare": "^10.48.0", - "@sentry/react": "^10.48.0", - "@tanstack/react-router": "catalog:", - "@tanstack/react-start": "catalog:", - "@workos-inc/node": "^8.11.1", - "agents": "^0.10.0", - "autumn-js": "^1.2.8", - "drizzle-orm": "catalog:", - "effect": "catalog:", - "jose": "^5.6.3", - "postgres": "^3.4.9", - "posthog-js": "^1.372.5", - "react": "catalog:", - "react-dom": "catalog:", - "sonner": "^2.0.7", - }, - "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.15.0", - "@cloudflare/workers-types": "^4.20250620.0", - "@effect/platform-node": "catalog:", - "@effect/vitest": "catalog:", - "@electric-sql/pglite": "^0.4.4", - "@electric-sql/pglite-socket": "^0.1.4", - "@executor-js/cli": "workspace:*", - "@rhyssul/portless": "^0.13.0", - "@tailwindcss/vite": "catalog:", - "@tanstack/virtual-file-routes": "^1.162.0", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "@vitejs/plugin-react": "catalog:", - "concurrently": "^9.2.1", - "drizzle-kit": "catalog:", - "jiti": "^2.6.1", - "typescript": "catalog:", - "vite": "catalog:", - "vitest": "^4.1.5", - "wrangler": "^4.81.0", - }, - }, - "apps/desktop": { - "name": "@executor-js/desktop", - "version": "1.5.18", - "dependencies": { - "@sentry/bun": "^10.57.0", - "@sentry/electron": "^7.13.0", - "electron-log": "^5", - "electron-store": "^10", - "electron-updater": "^6", - "electron-window-state": "^5.0.3", - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "@executor-js/app": "workspace:*", - "@executor-js/local": "workspace:*", - "@executor-js/plugin-desktop-settings": "workspace:*", - "@executor-js/plugin-file-secrets": "workspace:*", - "@executor-js/plugin-google": "workspace:*", - "@executor-js/plugin-graphql": "workspace:*", - "@executor-js/plugin-keychain": "workspace:*", - "@executor-js/plugin-mcp": "workspace:*", - "@executor-js/plugin-microsoft": "workspace:*", - "@executor-js/plugin-onepassword": "workspace:*", - "@executor-js/plugin-openapi": "workspace:*", - "@executor-js/runtime-quickjs": "workspace:*", - "@executor-js/sdk": "workspace:*", - "@jitl/quickjs-wasmfile-release-sync": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", - "@types/node": "catalog:", - "@zip.js/zip.js": "^2.8.26", - "bun-types": "catalog:", - "electron": "41.2.1", - "electron-builder": "^26", - "electron-vite": "^5", - "quickjs-emscripten": "catalog:", - "typescript": "catalog:", - "vite": "catalog:", - "vitest": "catalog:", - }, - }, "apps/host-cloudflare": { "name": "@executor-js/host-cloudflare", "dependencies": { @@ -417,6 +306,117 @@ "typescript": "latest", }, }, + "legacy/cloud": { + "name": "@executor-js/cloud", + "version": "1.4.36", + "dependencies": { + "@cloudflare/vite-plugin": "^1.31.1", + "@effect/atom-react": "catalog:", + "@effect/opentelemetry": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/cloudflare": "workspace:*", + "@executor-js/execution": "workspace:*", + "@executor-js/fumadb": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-google": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-microsoft": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/plugin-workos-vault": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/runtime-dynamic-worker": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@executor-js/vite-plugin": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "~1.9.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-logs": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/sdk-trace-web": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry/cloudflare": "^10.48.0", + "@sentry/react": "^10.48.0", + "@tanstack/react-router": "catalog:", + "@tanstack/react-start": "catalog:", + "@workos-inc/node": "^8.11.1", + "agents": "^0.10.0", + "autumn-js": "^1.2.8", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "jose": "^5.6.3", + "postgres": "^3.4.9", + "posthog-js": "^1.372.5", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.0", + "@cloudflare/workers-types": "^4.20250620.0", + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@electric-sql/pglite": "^0.4.4", + "@electric-sql/pglite-socket": "^0.1.4", + "@executor-js/cli": "workspace:*", + "@rhyssul/portless": "^0.13.0", + "@tailwindcss/vite": "catalog:", + "@tanstack/virtual-file-routes": "^1.162.0", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "concurrently": "^9.2.1", + "drizzle-kit": "catalog:", + "jiti": "^2.6.1", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "^4.1.5", + "wrangler": "^4.81.0", + }, + }, + "legacy/desktop": { + "name": "@executor-js/desktop", + "version": "1.5.18", + "dependencies": { + "@sentry/bun": "^10.57.0", + "@sentry/electron": "^7.13.0", + "electron-log": "^5", + "electron-store": "^10", + "electron-updater": "^6", + "electron-window-state": "^5.0.3", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/app": "workspace:*", + "@executor-js/local": "workspace:*", + "@executor-js/plugin-desktop-settings": "workspace:*", + "@executor-js/plugin-file-secrets": "workspace:*", + "@executor-js/plugin-google": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-keychain": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-microsoft": "workspace:*", + "@executor-js/plugin-onepassword": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@jitl/quickjs-wasmfile-release-sync": "catalog:", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "catalog:", + "@zip.js/zip.js": "^2.8.26", + "bun-types": "catalog:", + "electron": "41.2.1", + "electron-builder": "^26", + "electron-vite": "^5", + "quickjs-emscripten": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + }, + }, "packages/app": { "name": "@executor-js/app", "version": "1.4.4", @@ -1661,7 +1661,7 @@ "@executor-js/cli": ["@executor-js/cli@workspace:packages/core/cli"], - "@executor-js/cloud": ["@executor-js/cloud@workspace:apps/cloud"], + "@executor-js/cloud": ["@executor-js/cloud@workspace:legacy/cloud"], "@executor-js/cloudflare": ["@executor-js/cloudflare@workspace:packages/hosts/cloudflare"], @@ -1669,7 +1669,7 @@ "@executor-js/config": ["@executor-js/config@workspace:packages/core/config"], - "@executor-js/desktop": ["@executor-js/desktop@workspace:apps/desktop"], + "@executor-js/desktop": ["@executor-js/desktop@workspace:legacy/desktop"], "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], @@ -3085,19 +3085,19 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], - "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], + "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], + "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], + "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], "@webgpu/types": ["@webgpu/types@0.1.70", "", {}, "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA=="], @@ -5301,7 +5301,7 @@ "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], @@ -5469,7 +5469,7 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], @@ -5589,6 +5589,8 @@ "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "@astrojs/cloudflare/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@astrojs/cloudflare/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "@astrojs/react/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.9.0", "", { "dependencies": { "picomatch": "^4.0.4" } }, "sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg=="], @@ -5679,24 +5681,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@executor-js/api/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/cli/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/cloud/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/cloudflare/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/codemode-core/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/config/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/desktop/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "@executor-js/e2e/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], - "@executor-js/e2e/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/emulate/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], @@ -5705,24 +5691,8 @@ "@executor-js/example-promise-sdk/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "@executor-js/execution/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "@executor-js/fumadb/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/host-cloudflare/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/host-mcp/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/host-selfhost/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/integrations-registry/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/ir/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/local/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "@executor-js/mcporter/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/mcporter/rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], @@ -5731,46 +5701,10 @@ "@executor-js/motel/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], - "@executor-js/plugin-encrypted-secrets/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-example/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-file-secrets/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-google/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-graphql/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-keychain/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-mcp/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-microsoft/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-onepassword/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-openapi/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/plugin-workos-vault/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/runtime-deno-subprocess/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/runtime-dynamic-worker/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/runtime-quickjs/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/sdk/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/test-servers/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - - "@executor-js/vite-plugin/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "@executor-js/web/oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="], "@executor-js/web/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "@executor-js/web/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "@fastify/otel/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], "@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="], @@ -6117,12 +6051,16 @@ "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@tanstack/start-plugin-core/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.40", "", {}, "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w=="], "@tanstack/start-plugin-core/@tanstack/router-generator": ["@tanstack/router-generator@1.166.32", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.15", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "magic-string": "^0.30.21", "prettier": "^3.5.0", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-VuusKwEXcgKq+myq1JQfZogY8scTXIIeFls50dJ/UXgCXWp5n14iFreYNlg41wURcak2oA3M+t2TVfD0xUUD6g=="], + "@tanstack/start-plugin-core/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], @@ -6177,6 +6115,8 @@ "astro/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "astro/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "astro/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "atmn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -6269,8 +6209,6 @@ "execa/get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - "executor/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -6323,6 +6261,8 @@ "json-schema-to-typescript/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "json-schema-to-typescript/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -6351,6 +6291,8 @@ "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "node-gyp/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "node-gyp/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], @@ -6451,6 +6393,8 @@ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "sucrase/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "svelte/aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], @@ -6471,6 +6415,8 @@ "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tsup/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "typeorm/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "typeorm/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], @@ -6479,10 +6425,6 @@ "unstorage/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], - "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "vitest/vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], - "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], @@ -6509,6 +6451,8 @@ "@astrojs/react/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "@astrojs/react/vite/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@cloudflare/vite-plugin/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], @@ -6651,264 +6595,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@executor-js/api/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/api/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/api/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/api/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/api/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/api/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/api/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/api/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/cli/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/cli/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/cli/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/cli/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/cli/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/cli/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/cloud/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/cloud/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/cloud/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/cloud/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/cloud/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/cloud/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/cloud/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/cloud/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/cloudflare/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/cloudflare/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/cloudflare/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/cloudflare/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/cloudflare/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/cloudflare/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/cloudflare/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/cloudflare/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/codemode-core/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/codemode-core/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/codemode-core/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/codemode-core/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/codemode-core/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/codemode-core/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/codemode-core/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/codemode-core/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/config/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/config/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/config/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/config/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/config/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/config/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/config/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/config/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/desktop/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/desktop/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/desktop/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/desktop/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/desktop/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/desktop/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/desktop/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/desktop/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "@executor-js/e2e/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@executor-js/e2e/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/e2e/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/e2e/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/e2e/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/e2e/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/e2e/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/e2e/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/e2e/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/execution/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/execution/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/execution/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/execution/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/execution/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/execution/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/execution/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/execution/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/fumadb/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/fumadb/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/fumadb/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/fumadb/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/fumadb/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/fumadb/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/fumadb/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/fumadb/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/host-cloudflare/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/host-cloudflare/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/host-cloudflare/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/host-cloudflare/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/host-cloudflare/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/host-cloudflare/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/host-cloudflare/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/host-cloudflare/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/host-mcp/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/host-mcp/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/host-mcp/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/host-mcp/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/host-mcp/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/host-mcp/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/host-mcp/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/host-mcp/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/host-selfhost/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/host-selfhost/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/host-selfhost/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/host-selfhost/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/host-selfhost/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/host-selfhost/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/host-selfhost/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/host-selfhost/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/integrations-registry/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/integrations-registry/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/integrations-registry/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/integrations-registry/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/integrations-registry/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/integrations-registry/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/integrations-registry/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/integrations-registry/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/ir/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/ir/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/ir/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/ir/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/ir/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/ir/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/ir/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/ir/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/local/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/local/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/local/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/local/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/local/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/local/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/local/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/local/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "@executor-js/mcporter/rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@executor-js/mcporter/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], @@ -6955,278 +6643,6 @@ "@executor-js/motel/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@executor-js/plugin-encrypted-secrets/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-encrypted-secrets/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-encrypted-secrets/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-encrypted-secrets/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-encrypted-secrets/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-encrypted-secrets/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-encrypted-secrets/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-encrypted-secrets/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-example/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-example/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-example/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-example/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-example/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-example/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-example/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-example/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-file-secrets/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-file-secrets/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-file-secrets/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-file-secrets/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-file-secrets/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-file-secrets/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-file-secrets/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-file-secrets/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-google/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-google/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-google/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-google/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-google/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-google/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-google/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-google/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-graphql/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-graphql/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-graphql/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-graphql/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-graphql/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-graphql/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-graphql/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-graphql/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-keychain/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-keychain/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-keychain/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-keychain/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-keychain/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-keychain/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-keychain/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-keychain/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-mcp/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-mcp/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-mcp/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-mcp/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-mcp/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-mcp/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-mcp/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-mcp/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-microsoft/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-microsoft/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-microsoft/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-microsoft/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-microsoft/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-microsoft/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-microsoft/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-microsoft/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-onepassword/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-onepassword/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-onepassword/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-onepassword/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-onepassword/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-onepassword/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-onepassword/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-onepassword/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-openapi/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-openapi/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-openapi/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-openapi/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-openapi/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-openapi/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-openapi/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-openapi/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/plugin-workos-vault/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/plugin-workos-vault/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/plugin-workos-vault/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/plugin-workos-vault/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/plugin-workos-vault/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/plugin-workos-vault/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/plugin-workos-vault/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/plugin-workos-vault/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/runtime-deno-subprocess/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/runtime-deno-subprocess/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/runtime-deno-subprocess/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/runtime-deno-subprocess/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/runtime-deno-subprocess/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/runtime-deno-subprocess/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/runtime-deno-subprocess/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/runtime-deno-subprocess/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/runtime-dynamic-worker/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/runtime-dynamic-worker/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/runtime-dynamic-worker/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/runtime-dynamic-worker/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/runtime-dynamic-worker/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/runtime-dynamic-worker/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/runtime-dynamic-worker/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/runtime-dynamic-worker/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/runtime-quickjs/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/runtime-quickjs/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/runtime-quickjs/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/runtime-quickjs/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/runtime-quickjs/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/runtime-quickjs/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/runtime-quickjs/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/runtime-quickjs/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/sdk/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/sdk/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/sdk/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/sdk/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/sdk/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/sdk/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/sdk/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/sdk/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/test-servers/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/test-servers/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/test-servers/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/test-servers/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/test-servers/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/test-servers/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/test-servers/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/test-servers/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "@executor-js/vite-plugin/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/vite-plugin/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/vite-plugin/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/vite-plugin/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/vite-plugin/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/vite-plugin/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/vite-plugin/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/vite-plugin/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "@executor-js/web/oxlint/@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.71.0", "", { "os": "android", "cpu": "arm" }, "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g=="], "@executor-js/web/oxlint/@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.71.0", "", { "os": "android", "cpu": "arm64" }, "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw=="], @@ -7265,22 +6681,6 @@ "@executor-js/web/oxlint/@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="], - "@executor-js/web/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@executor-js/web/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@executor-js/web/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@executor-js/web/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@executor-js/web/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@executor-js/web/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@executor-js/web/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "@executor-js/web/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="], "@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], @@ -7379,6 +6779,8 @@ "@tanstack/router-generator/@tanstack/router-core/seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "@tanstack/router-generator/@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@tanstack/router-plugin/@tanstack/router-generator/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "@tanstack/start-plugin-core/@tanstack/router-generator/@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], @@ -7601,21 +7003,7 @@ "electron-vite/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], - "executor/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "executor/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "executor/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "executor/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "executor/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "executor/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "executor/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - - "executor/vitest/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "electron-vite/vite/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -7701,10 +7089,6 @@ "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "vitest/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], - - "vitest/vite/rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], - "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -7947,42 +7331,6 @@ "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "vitest/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], - - "vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], - - "vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], - - "vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], - - "vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], - - "vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], - - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], - - "vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], - - "vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], - - "vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], - "@executor-js/mcporter/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], @@ -7995,12 +7343,6 @@ "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], } } diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..cd44517b5 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,65 @@ +name: executor + +services: + executor: + build: + context: . + dockerfile: Dockerfile + image: executor:local + command: + - server + - --bind + - 0.0.0.0:4788 + - --allow-unsafe-http-non-loopback + restart: unless-stopped + init: true + read_only: true + mem_limit: "${EXECUTOR_MEMORY_LIMIT:-2g}" + pids_limit: "${EXECUTOR_PIDS_LIMIT:-512}" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + ports: + - "127.0.0.1:4788:4788" + environment: + EXECUTOR_DATA_DIR: /var/lib/executor + # When changing the published port, also set EXECUTOR_PUBLIC_ORIGIN to + # the exact URL used in the browser. Keep plain HTTP on host loopback. + EXECUTOR_PUBLIC_ORIGIN: "${EXECUTOR_PUBLIC_ORIGIN:-http://127.0.0.1:4788}" + EXECUTOR_MCP_STDIO_TEMPLATES_FILE: /etc/executor/mcp-stdio.json + # Null means pass through only when the host variable is set. + EXECUTOR_TRUSTED_PROXIES: + RUST_LOG: "${RUST_LOG:-executor=info}" + volumes: + - executor-data:/var/lib/executor + - ./docker/mcp-stdio-templates.example.json:/etc/executor/mcp-stdio.json:ro + # By default, Executor creates master.key with mode 0600 in the data + # volume. To supply an external exact 32-byte key instead, make it + # readable by container UID 10001, mount it read-only, and add + # EXECUTOR_MASTER_KEY_FILE=/run/secrets/executor-master.key above. + # - ./secrets/executor-master.key:/run/secrets/executor-master.key:ro + # Mount only fully trusted, self-contained Linux stdio MCP executables. + # They run as Executor's UID and can read the data volume and master key. + # Use the absolute container paths from mcp-stdio.json. The base image + # does not include Node or Bun. Writeable state must remain under a + # mounted volume or /tmp. + # - /absolute/host/mcp:/opt/executor/mcp:ro + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m + healthcheck: + test: + - CMD + - curl + - --fail + - --silent + - --show-error + - http://127.0.0.1:4788/healthz + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + stop_grace_period: 2m + +volumes: + executor-data: diff --git a/docker/mcp-stdio-templates.example.json b/docker/mcp-stdio-templates.example.json new file mode 100644 index 000000000..da024f706 --- /dev/null +++ b/docker/mcp-stdio-templates.example.json @@ -0,0 +1,3 @@ +{ + "templates": [] +} diff --git a/docker/mcp-stdio-templates.full.example.json b/docker/mcp-stdio-templates.full.example.json new file mode 100644 index 000000000..0e1afe76f --- /dev/null +++ b/docker/mcp-stdio-templates.full.example.json @@ -0,0 +1,14 @@ +{ + "templates": [ + { + "name": "filesystem", + "executable": "/opt/executor/mcp/filesystem-server", + "cwd": "/var/lib/executor", + "arguments": ["--stdio"], + "environment": { + "LOG_LEVEL": "warn" + }, + "secretEnvironment": ["API_TOKEN"] + } + ] +} diff --git a/docs/cli.md b/docs/cli.md index 2807bccbd..374e89cf1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,13 +2,13 @@ The `executor` binary is both the self-hosted server and its local client. Client commands connect to a running server. They do not open the SQLite database or -start another server. +start another server. The server never auto-starts for a client command. ## Connection The default server is `http://127.0.0.1:4788`. Override it with -`--base-url` or `EXECUTOR_BASE_URL`. Create an API token in the dashboard and -provide it through `EXECUTOR_API_TOKEN`: +`--base-url` or `EXECUTOR_BASE_URL`. Sign in as the administrator, create an +API token under **API tokens**, and provide it through `EXECUTOR_API_TOKEN`: ```sh export EXECUTOR_API_TOKEN='copy-the-token-from-the-dashboard' @@ -21,14 +21,22 @@ Authorization header. Executor never puts them in URLs. Plain HTTP is accepted only for localhost and literal loopback addresses. Use HTTPS for another host. `--allow-insecure-http` is an explicit escape hatch for -a trusted network where transport security is handled elsewhere. +an authenticated and encrypted tunnel or equivalent transport that protects +the complete path. It sends the reusable bearer token over clear HTTP, so a +private LAN by itself is not sufficient protection. Use `--json` for machine-readable output. Errors and approval instructions go to stderr, leaving stdout available for JSON and MCP protocol messages. +Connection, authentication, validation, and protocol failures exit nonzero. An +upstream tool failure is a completed call: `--json` prints its result envelope +and the process exits successfully. Automation must inspect the envelope's +`ok` and `error` fields instead of treating exit status alone as tool success. + ## Tools -Search the enabled global catalog: +Search the non-disabled global catalog. Enabled and Ask tools are discoverable; +Disabled tools are omitted: ```sh executor tools search issue @@ -45,6 +53,10 @@ executor call github.issues_create '{"owner":"acme","repo":"app","title":"Bug"}' executor call github issues_create @arguments.json ``` +Paths contain exactly the source slug and local tool name. A leading `tools.` +is accepted for a dotted path, but deeper legacy resource or method segments +are not. + Each call gets one idempotency key that remains stable across safe HTTP retries. An Ask-mode tool prints and opens the clean dashboard approval URL, then polls as the owning API token until the approval completes. Press Ctrl-C to request @@ -65,7 +77,9 @@ already be running. ## Dashboard -Open the dashboard without putting credentials in the URL: +Open the dashboard without putting credentials in the URL. This command does +not require an API token. The browser still requires the administrator login +cookie: ```sh executor open diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 000000000..b6276931b --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,102 @@ +# Run Executor with Docker + +The root `Dockerfile` builds the Svelte application and embeds it into the Rust +binary. Its runtime image contains that one binary plus CA certificates and the +healthcheck client. Project and dependency license notices are installed under +`/usr/share/licenses/executor`. It supports Linux `amd64` and `arm64` images. + +## Start the local instance + +```sh +docker compose up --detach --build +docker compose logs executor +``` + +Open `http://127.0.0.1:4788`. On first boot, the logs contain the one-time setup +URL. The compose configuration publishes the port only on host loopback. Keep +that binding when using plain HTTP. + +The named `executor-data` volume contains SQLite state, its WAL files, and the +generated `master.key`. Back up the whole volume while the service is stopped. +The database and key are one recovery unit. Losing the key makes encrypted +credentials and protected state unrecoverable, and inventing a new key beside +an existing database is intentionally rejected. + +```sh +docker compose stop executor +# Snapshot the complete executor-data volume with your normal volume backup tool. +docker compose start executor +``` + +If you configure `EXECUTOR_MASTER_KEY_FILE`, the mounted key must contain +exactly 32 cryptographically random raw bytes from a secure random source or +secret manager and be readable by UID `10001`. Do not use a password, repeated +bytes, hand-authored content, hex text, or base64 text. Include that external +file in the same stopped recovery snapshot as the volume. Executor does not +create, chmod, rotate, or recover an external key. + +The shipped Compose file does not pass this optional variable by default. Add +an override such as: + +```yaml +services: + executor: + environment: + EXECUTOR_MASTER_KEY_FILE: /run/secrets/executor-master.key + volumes: + - ./secrets/executor-master.key:/run/secrets/executor-master.key:ro +``` + +The container runs as UID and GID `10001`, uses a read-only root filesystem, +drops Linux capabilities, and receives a private temporary filesystem. If an +external backup or secret manager creates mounted files, make them readable by +UID `10001` without making them writable by other users. + +## HTTPS reverse proxy + +Leave the compose port bound to host loopback and set the exact browser-facing +origin before starting the service: + +```sh +EXECUTOR_PUBLIC_ORIGIN=https://executor.example.com docker compose up --detach +``` + +Terminate TLS in a reverse proxy on the same host and forward to +`127.0.0.1:4788`. The compose file passes through +`EXECUTOR_TRUSTED_PROXIES` only when that host variable is set. Configure it +only when Executor must use forwarded client addresses, and include only the +proxy CIDRs that directly append or replace `X-Forwarded-For`. + +## MCP stdio templates + +The default mounted registry is empty and boot-safe. Start a private registry +from the full example: + +```sh +mkdir -p .executor-local +cp docker/mcp-stdio-templates.full.example.json \ + .executor-local/mcp-stdio-templates.json +``` + +Edit the `compose.yaml` bind mount so its host side is +`./.executor-local/mcp-stdio-templates.json`, then mount each referenced Linux +executable and working directory at its exact absolute container path. + +The runtime image does not include Node.js or Bun. Mounted stdio servers must be +self-contained Linux executables for the image architecture, or you must derive +a runtime image containing their dependencies. Treat every configured stdio +executable as fully trusted: it runs as Executor's UID and can read the data +volume and master key. + +## Multi-platform image check + +Release automation can build both supported architectures from the same file: + +```sh +docker buildx build --platform linux/amd64,linux/arm64 --file Dockerfile . +``` + +The native artifact workflow currently builds release archives without +publishing this root Rust image. A separate older workflow still publishes the +previous TypeScript self-host image, so do not treat that tag as this rewrite +until the release wiring is updated. diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 000000000..772ab168a --- /dev/null +++ b/docs/install.md @@ -0,0 +1,177 @@ +# Install and run Executor + +Executor ships as one native binary with its Svelte dashboard embedded. The +supported native targets are Linux and macOS on x86-64 and ARM64. Windows is not +supported. Docker images support Linux `amd64` and `arm64`. + +## Prerequisites + +Building from a checkout requires Bun 1.3.x, Rust 1.96 or newer, a C toolchain, +and the normal SQLite build prerequisites for the platform. The workspace +declares Bun 1.3.11 and the release workflow currently uses Rust 1.96.0. + +Run `bun run bootstrap` once in a fresh checkout. It installs workspace +dependencies, prepares retained TypeScript packages, and installs Playwright +Chromium for browser scenarios. + +## Release archive + +The native artifact workflow currently uploads archives to GitHub Actions but +does not publish them to a GitHub Release. Until a release owner publishes +those files, use the source-build or Docker path below. After a release is +published, install its checksum-verified archive with: + +```sh +curl -fsSL https://raw.githubusercontent.com/RhysSullivan/executor/main/scripts/install.sh | bash +``` + +Export the repository before running the script when using a fork: + +```sh +export EXECUTOR_REPOSITORY=owner/repository +curl -fsSL "https://raw.githubusercontent.com/$EXECUTOR_REPOSITORY/main/scripts/install.sh" | bash +``` + +Forward installer options after `bash -s --`, for example: + +```sh +curl -fsSL "https://raw.githubusercontent.com/$EXECUTOR_REPOSITORY/main/scripts/install.sh" \ + | bash -s -- --version 2.0.0 --no-modify-path +``` + +The installer defaults to +`$HOME/.executor/bin`. Start a new shell, source its configuration file, or use +`$HOME/.executor/bin/executor` until the updated PATH is active. + +The release artifact workflow creates these archives and matching `.sha256` +sidecars: + +- `executor-x86_64-unknown-linux-gnu.tar.gz` +- `executor-aarch64-unknown-linux-gnu.tar.gz` +- `executor-x86_64-apple-darwin.tar.gz` +- `executor-aarch64-apple-darwin.tar.gz` + +Each archive also contains the project `LICENSE`, the generated Rust +`THIRD_PARTY_LICENSES.html` inventory, and Vite's bundle-derived embedded-web +`THIRD_PARTY_JAVASCRIPT_LICENSES.json` inventory. The Linux binaries require +glibc 2.35 or newer, matching the Ubuntu 22.04 release baseline. Use Docker on +older Linux distributions. + +The workflow creates `SHA256SUMS`, runs every target natively, smoke-tests each +binary, and records GitHub build-provenance attestations for the archives. The +current workflow uploads Actions artifacts only. Publishing remains an +explicit, human-approved release action: + +```sh +tag=v2.0.0 +run_id=RUN_ID +git fetch origin "refs/tags/$tag:refs/tags/$tag" +test "$(gh run view "$run_id" --json workflowName --jq .workflowName)" = \ + "Build release artifacts" +test "$(gh run view "$run_id" --json event --jq .event)" = push +test "$(gh run view "$run_id" --json conclusion --jq .conclusion)" = success +test "$(gh run view "$run_id" --json headBranch --jq .headBranch)" = "$tag" +test "$(gh run view "$run_id" --json headSha --jq .headSha)" = \ + "$(git rev-parse "$tag^{commit}")" +gh run download "$run_id" --name executor-release-artifacts --dir release-assets +(cd release-assets && sha256sum --check SHA256SUMS) +gh attestation verify release-assets/executor-x86_64-unknown-linux-gnu.tar.gz \ + --repo OWNER/REPOSITORY +gh release create "$tag" --draft --verify-tag +gh release upload "$tag" release-assets/* +``` + +Verify all four archive attestations before uploading. The run checks bind the +downloaded artifacts to the exact tag commit, rather than an arbitrary manual +workflow run. Publishing the draft is a separate deliberate action. The +installer becomes operational only after the archives and checksum sidecars +are attached to that GitHub Release. + +## Build a local release binary + +Install the prerequisites above, then run from the repository root: + +```sh +bun run bootstrap +bun run --cwd web build +cargo build --locked --release +./scripts/install.sh --binary ./target/release/executor +``` + +The web build must happen before the release Cargo build so `build.rs` can +embed the static application. Node.js or Bun is not required after compilation. + +## First boot + +For an easy-to-inspect local instance, choose an explicit private data +directory: + +```sh +mkdir -p "$HOME/.executor-data" +chmod 0700 "$HOME/.executor-data" +"$HOME/.executor/bin/executor" server --data-dir "$HOME/.executor-data" +``` + +Executor binds to `127.0.0.1:4788` by default. Open the one-time setup URL +printed at startup and create the administrator login. The dashboard immediately +signs in with those credentials. If that follow-up login fails, sign in manually, +then generate API tokens under **API tokens**. Token secrets are shown once. +The setup token expires after 15 minutes and is replaced on a later boot until +an administrator is created. + +The data directory contains the SQLite database, WAL and SHM files when +present, process lock, and generated `master.key`. Back up the complete +directory only while Executor is stopped. Keep the database and key together +and private. + +To use an externally managed key, supply an exact 32-byte cryptographically +random raw file from a secure random source or secret manager, restrict it to +the Executor service identity, and pass `--master-key-file` or +`EXECUTOR_MASTER_KEY_FILE`. This setting is a file path, never key material. +Do not use a password, repeated bytes, hand-authored content, hex text, or +base64 text as the key. Executor does not create, chmod, rotate, or recover an +external key. Include +that file in the same stopped recovery snapshot as the data directory. Losing +the key makes protected credentials, OAuth tokens, approvals, and results +unrecoverable. + +Use `--public-origin https://executor.example.com` when a local HTTPS reverse +proxy exposes the dashboard at another origin. An HTTPS public origin does not +encrypt or firewall Executor's own listener. Keep the listener on loopback, or +enforce network isolation so clients can reach it only through the TLS proxy. +Do not expose the raw plaintext port. + +The one-time setup URL is a secret until setup completes. It may appear in the +terminal, journal, or private service log, so protect those logs accordingly. + +## Server configuration + +| Environment variable | Server flag | Purpose | +| ----------------------------------- | ----------------------- | ---------------------------------------------- | +| `EXECUTOR_DATA_DIR` | `--data-dir` | SQLite, lock, and default master-key directory | +| `EXECUTOR_MASTER_KEY_FILE` | `--master-key-file` | Existing raw 32-byte master-key file | +| `EXECUTOR_MCP_STDIO_TEMPLATES_FILE` | `--mcp-stdio-templates` | Trusted local MCP template registry | +| `EXECUTOR_PUBLIC_ORIGIN` | `--public-origin` | Exact browser-facing HTTP or HTTPS origin | +| `EXECUTOR_TRUSTED_PROXIES` | `--trusted-proxy` | Comma-separated trusted reverse-proxy CIDRs | +| `RUST_LOG` | none | Rust tracing filter, default `executor=info` | + +`--bind` and `--allow-unsafe-http-non-loopback` intentionally have no +environment equivalents. Do not put credentials, API tokens, or raw key +material in service environment files. + +Configure trusted proxies only when Executor must derive client addresses from +`X-Forwarded-For`. List only the actual proxy CIDRs, include every hop in the +chain, and configure each proxy to append or replace the header correctly. A +broad trust range can permit spoofed login identities, while an incomplete +chain causes forwarded logins to be rejected. + +## Managed services and containers + +- [Linux systemd service](systemd.md) +- [macOS LaunchAgent](launchd.md) +- [Docker Compose](docker.md) + +MCP stdio templates are machine-admin allowlists. Every approved executable is +fully trusted because it runs with Executor's operating-system identity and can +access its data and master key. See [MCP support](mcp.md) for the exact template +schema and transport limits. diff --git a/docs/launchd.md b/docs/launchd.md new file mode 100644 index 000000000..7d4a043b4 --- /dev/null +++ b/docs/launchd.md @@ -0,0 +1,106 @@ +# Run Executor with launchd + +The native macOS release supports both Apple Silicon and Intel Macs. The +LaunchAgent installer uses templates from a source checkout. Install the single +binary first, then run the service installer from that checkout: + +```sh +./scripts/install.sh --binary ./target/release/executor +./scripts/install-launchd.sh --binary "$HOME/.executor/bin/executor" +``` + +When published release archives are available, the first command can omit +`--binary`. Pass the absolute binary path explicitly because a PATH edit from +the binary installer is not active in the current shell. + +The LaunchAgent runs only while that user is logged in. It binds Executor to +`127.0.0.1:4788`, keeps persistent state under +`~/Library/Application Support/Executor`, and stops it with `SIGTERM` so the +server can drain active work before launchd's 30-second exit deadline. + +Executor creates `master.key` inside the data directory on first boot. Back up +the data directory and master key together. Losing the key makes encrypted +credentials and protected state unrecoverable. Keep the directory private and +never copy the key into a shell command, plist, log, or source-control file. + +The installer creates an empty, mode `0600` MCP stdio template registry at +`~/Library/Application Support/Executor/mcp-stdio-templates.json`. Edit that +file to add trusted local executables, then restart Executor. Do not make the +template file, referenced executable, working directory, or any ancestor path +writable by untrusted users. + +Treat every configured stdio executable as fully trusted. launchd runs it as +the same user as Executor, so it can read that user's Executor data directory +and master key. The template allowlist prevents dashboard users from selecting +arbitrary commands, but it is not a privilege boundary between Executor and an +approved executable. + +## Operate the service + +```sh +launchctl print "gui/$(id -u)/dev.executor.gateway" +launchctl bootout "gui/$(id -u)/dev.executor.gateway" +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/dev.executor.gateway.plist" +``` + +A graceful restart uses `bootout` followed by `bootstrap`, as shown above. +`kickstart -k` force-kills the process and should be reserved for a stuck +service that cannot exit normally. + +Executor output is written to `executor.log` inside its mode `0700` data +directory. The generated LaunchAgent helper keeps the current log and three +rotated generations, each capped at 1 MiB and mode `0600`. This keeps the +first-boot setup token in the user's private data directory without allowing +normal tracing output to grow without a bound. + +```sh +tail -f "$HOME/Library/Application Support/Executor/executor.log" +``` + +The one-time setup URL appears in that output on first boot. + +To serve the dashboard through an HTTPS reverse proxy, reinstall the agent with +the exact browser-facing origin: + +```sh +./scripts/install-launchd.sh \ + --binary "$HOME/.executor/bin/executor" \ + --public-origin https://executor.example.com +``` + +Leave Executor on its default loopback bind. The local reverse proxy should be +the only process that exposes it to other machines. Configure trusted proxy +CIDRs only when forwarded client addresses are required: + +```sh +./scripts/install-launchd.sh \ + --binary "$HOME/.executor/bin/executor" \ + --public-origin https://executor.example.com \ + --trusted-proxy 127.0.0.1/32 \ + --trusted-proxy ::1/128 +``` + +`EXECUTOR_TRUSTED_PROXIES` accepts the same CIDRs as a comma-separated list. + +To remove the service without deleting data: + +```sh +launchctl bootout "gui/$(id -u)/dev.executor.gateway" || true +rm "$HOME/Library/LaunchAgents/dev.executor.gateway.plist" +``` + +## Back up and restore + +Stop Executor before copying its SQLite database, WAL files, and master key. +Back up the whole data directory as one unit: + +```sh +launchctl bootout "gui/$(id -u)/dev.executor.gateway" +cp -pR "$HOME/Library/Application Support/Executor" "$HOME/Executor-backup" +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/dev.executor.gateway.plist" +``` + +To restore, stop the service, replace the complete data directory from one +backup, preserve its private permissions, then start the service. Never create +a replacement key beside an existing `executor.db`; Executor intentionally +fails closed when the initialized database and its original key are separated. diff --git a/docs/sources.md b/docs/sources.md new file mode 100644 index 000000000..30ea6370c --- /dev/null +++ b/docs/sources.md @@ -0,0 +1,194 @@ +# Sources, credentials, and managed OAuth + +Source management is available only to the signed-in administrator. API tokens +can discover and call the resulting global catalog, but cannot create sources, +change credentials, or alter tool modes. + +## Before connecting + +Open **Sources** in the dashboard. Give each source a clear name and, when +offered, a stable preferred slug. Tool paths use +`tools..`. + +Outbound connections ignore ambient HTTP proxy variables and do not follow +tool-call redirects. Public targets are allowed by default. Enable **Allow +private network addresses** only when the source intentionally runs on +loopback or a private network. Link-local, cloud metadata, multicast, and +reserved targets remain blocked. + +For a managed OAuth source, that same opt-in also covers issuer discovery, MCP +protected-resource discovery, authorization metadata, token exchange, and +refresh endpoints. It can permit loopback HTTP for those OAuth endpoints. Keep +the opt-in off unless both the source and its OAuth provider intentionally need +private-network reachability. + +Credentials are encrypted with the instance master key and are never shown +again. Enter secrets in the dashboard, not in endpoint URLs. Static credentials +take precedence over a managed OAuth connection for the same credential key. + +## OpenAPI + +1. Choose **OpenAPI service**. +2. Select **URL** or **Paste**, then provide an OpenAPI 3.0 or 3.1 document in + JSON or YAML. +3. Select **Preview specification** and review the detected operations and + security schemes. +4. Enter any API key, bearer, basic, or manual OAuth access-token credentials. +5. Import the source, then review its effective tool modes under **Tools**. + +Swagger 2 and external references are not supported. For a URL import, the +document's server information determines the upstream request target. Query +parameters in a source URL are treated as protected credential state and are +not shown in source metadata. + +Managed OAuth becomes available after import for supported authorization-code +security schemes. Configure each eligible scheme separately in the source's +**Managed OAuth** panel. + +## GraphQL + +1. Choose **GraphQL API**. +2. Enter the GraphQL endpoint and source name. +3. Select static authentication when you intend to use it. For managed OAuth, + choose **None**. +4. Connect the source and review its imported queries and mutations. + +Introspection must succeed before tools can be imported and on later refreshes. +With no static credential, a recognized authentication-required response +stages an empty source so you can configure its managed OAuth `default` +credential, authorize it, and then refresh the catalog. +Queries start Enabled, mutations start Ask, and deprecated operations start +Disabled. Managed OAuth uses the GraphQL source's `default` credential. + +## MCP Streamable HTTP + +1. Choose **MCP Streamable HTTP**. +2. Enter the endpoint and source name. +3. Select no authentication, bearer, basic, API-key header, or a manually + managed OAuth access token. +4. Connect, then review imported tools and their modes. + +Executor currently requires MCP protocol version `2025-11-25`. It imports MCP +tools only. An upstream tool starts Enabled only when the upstream explicitly +marks it read-only without also marking it destructive. All other imported MCP +tools start Ask. + +For managed OAuth, first create the HTTP MCP source without a static +credential, then configure its `default` credential in **Managed OAuth**. See +[MCP support](mcp.md) for transport limits and lifecycle details. + +## MCP stdio templates + +Dashboard users cannot submit arbitrary commands. A machine administrator must +approve the executable, arguments, working directory, static environment, and +names of secret environment fields in a JSON template file. Start Executor +with: + +```sh +executor server \ + --mcp-stdio-templates /absolute/path/to/mcp-stdio-templates.json +``` + +The equivalent environment variable is +`EXECUTOR_MCP_STDIO_TEMPLATES_FILE`. The file format is: + +```json +{ + "templates": [ + { + "name": "filesystem", + "executable": "/absolute/path/to/mcp-server", + "cwd": "/absolute/path/to/approved-directory", + "arguments": ["--stdio"], + "environment": { + "LOG_LEVEL": "warn" + }, + "secretEnvironment": ["API_TOKEN"] + } + ] +} +``` + +Restart Executor after editing the registry. In **Sources**, choose **MCP local +template**, select the approved template, and enter every required secret. The +template allowlist is a command-selection control, not a sandbox between +Executor and the selected process. Every approved executable runs with +Executor's operating-system identity and must be fully trusted. + +Protect the template file, executable, working directory, and every ancestor +path from untrusted writes. Executor canonicalizes and validates configured +targets when it loads the registry, but it does not enforce their ownership or +permissions. + +The systemd and launchd installers create empty template registries at their +documented service paths. Docker mounts the repository's empty example by +default. Container templates must reference Linux executables available at the +same absolute path inside the container. + +## Managed OAuth + +Managed OAuth supports eligible OpenAPI authorization-code schemes, the +GraphQL `default` credential, and the HTTP MCP `default` credential. It uses +authorization-code flow with PKCE and stores access and refresh tokens +encrypted. This is OAuth 2 only. OpenID Connect and the `openid` scope are not +supported. + +Before configuring OAuth, start Executor with the exact browser-facing origin: + +```sh +executor server --public-origin https://executor.example.com +``` + +Or set: + +```sh +EXECUTOR_PUBLIC_ORIGIN=https://executor.example.com +``` + +The value must be an origin only, with no path, query, fragment, or embedded +credentials. It controls control-mutation Origin checks, MCP Host and Origin +validation, cookie security, setup links, and OAuth callbacks. Keep Executor +bound to loopback and terminate TLS at a local reverse proxy. + +Then: + +1. Import the source. +2. Open its **Managed OAuth** panel. +3. Choose the eligible credential and enter the provider issuer, client ID, + client authentication method, optional client secret, and requested scopes. + For HTTP MCP, provider discovery can begin from the MCP protected resource. +4. Save the connection. +5. Copy the callback URL displayed by Executor and register that exact URL with + the provider. +6. Select **Connect OAuth** and finish authorization in the provider window. +7. Return to the source and refresh its catalog if it was created before + authorization completed. + +Callback paths are connection-specific: + +```text +/api/v1/oauth/callback/ +``` + +Do not replace them with one shared callback path. If the provider revokes the +grant or refresh fails, the dashboard marks the connection for +reauthorization. Disconnecting removes active token material without deleting +the source. + +## Tool modes and refresh + +Each tool has an intrinsic mode, an optional source override, and an optional +tool override. Effective mode precedence is: + +1. Tool override +2. Source override +3. Intrinsic mode + +Enabled calls run immediately. Ask calls wait for the administrator under +**Approvals**. Disabled tools stay visible to the administrator but are hidden +from gateway search and cannot be invoked. + +Use **Refresh** after an upstream OpenAPI document, GraphQL schema, or MCP tool +list changes. A failed refresh leaves the last successfully imported catalog +in place. Deleting and recreating a source intentionally discards its stable +tool identity and tombstone history. diff --git a/docs/systemd.md b/docs/systemd.md new file mode 100644 index 000000000..7fb1ef185 --- /dev/null +++ b/docs/systemd.md @@ -0,0 +1,146 @@ +# Linux systemd service + +Executor ships a hardened system service for Linux distributions that use +systemd. It runs as a dedicated unprivileged `executor` account and listens on +`127.0.0.1:4788` by default. + +## Install + +Build or download the Executor binary, then run the installer from a source +checkout: + +```sh +sudo scripts/install-systemd.sh --binary /path/to/executor +``` + +Use `--no-start` to inspect or customize the installed files before the first +boot. The installer is safe to run again during an upgrade. It replaces the +binary and unit but never replaces the master key, environment file, or MCP +stdio template registry. + +The installed paths are: + +| Path | Purpose | Ownership and mode | +| ---------------------------------------- | -------------------------------- | --------------------------- | +| `/usr/local/bin/executor` | Rust server and CLI binary | `root:root`, `0755` | +| `/etc/systemd/system/executor.service` | systemd unit | `root:root`, `0644` | +| `/etc/executor/executor.env` | Non-secret service configuration | `root:executor`, `0640` | +| `/etc/executor/mcp-stdio-templates.json` | Approved local MCP commands | `root:executor`, `0640` | +| `/var/lib/executor` | SQLite state and protected data | `executor:executor`, `0700` | +| `/var/lib/executor/master.key` | Raw 32-byte instance master key | `executor:executor`, `0600` | + +Back up the SQLite database and `master.key` together. For a simple filesystem +backup, stop the service and copy the complete `/var/lib/executor` directory, +then start it again. Losing the key makes encrypted credentials and initialized +instance state unrecoverable. Replacing the key while keeping the database +makes startup fail closed. The installer refuses to invent a key for an +existing database. + +## First boot and operation + +The one-time setup URL is written to the journal on first boot: + +```sh +sudo journalctl -u executor.service -b +``` + +Complete setup in a browser, then create API tokens in the dashboard. Routine +service commands are: + +```sh +sudo systemctl status executor.service +sudo systemctl restart executor.service +sudo systemctl stop executor.service +sudo journalctl -u executor.service -f +``` + +`systemctl stop` sends `SIGTERM`. Executor stops accepting work, cancels owned +waits, drains upstream MCP processes and background tasks, closes SQLite, and +then exits. systemd allows 90 seconds for this graceful path before forcing +termination. + +## Public origin and reverse proxies + +The default unit is intentionally loopback-only. For a browser-facing reverse +proxy, keep the bind address on loopback and set the exact external HTTPS +origin in `/etc/executor/executor.env`: + +```sh +EXECUTOR_PUBLIC_ORIGIN=https://executor.example.com +``` + +This origin controls browser origin checks, setup links, and managed OAuth +callback URLs. It must contain only a scheme, host, and optional port. + +Only configure `EXECUTOR_TRUSTED_PROXIES` when Executor must use forwarded +client addresses. List every proxy CIDR in the chain: + +```sh +EXECUTOR_TRUSTED_PROXIES=127.0.0.1/32,::1/128 +``` + +Restart the service after changing the environment file. Do not bind plaintext +HTTP to a non-loopback address. Terminate HTTPS at the local reverse proxy or +another explicitly trusted transport boundary. + +## Local MCP stdio templates + +Treat every approved MCP stdio executable as fully trusted with the complete +Executor instance. It runs under the same operating-system account as Executor +and can read the master key and database. The service-level sandbox limits what +the combined service can reach, but it is not a security boundary between the +Rust server and its stdio children. Do not approve untrusted executables. + +The installer creates an empty template registry at +`/etc/executor/mcp-stdio-templates.json`. Start from +`packaging/systemd/mcp-stdio-templates.example.json` when approving a local MCP +server. Executables and working directories must use absolute paths and must be +readable or executable by the `executor` account. Put secret values in the +dashboard, not in the template or environment file. + +The unit applies `ProtectSystem=strict`, `ProtectHome=yes`, and `PrivateTmp=yes` +to Executor and its MCP children. A child can read system paths, has private +temporary directories, and can persist writes only within `/var/lib/executor` +by default. Home directories are hidden. Grant only the paths a reviewed +template needs with a systemd drop-in: + +```sh +sudo systemctl edit executor.service +``` + +For example: + +```ini +[Service] +ReadOnlyPaths=/srv/reference-data +ReadWritePaths=/srv/executor-workspace +``` + +Create those paths with ownership and modes appropriate for the `executor` +account, then run: + +```sh +sudo systemctl daemon-reload +sudo systemctl restart executor.service +``` + +The service-level sandbox is inherited by every MCP stdio child. Do not weaken +the whole unit when one child needs a narrow filesystem exception. Any +supplemental groups assigned to the `executor` account are inherited too, so +review those memberships as part of every template approval. + +## Upgrade or remove + +To upgrade, rerun the installer with the replacement binary. The service is +restarted only after the new binary and unit are installed. + +To remove the service while preserving data: + +```sh +sudo systemctl disable --now executor.service +sudo rm /etc/systemd/system/executor.service +sudo systemctl daemon-reload +``` + +Remove `/etc/executor`, `/var/lib/executor`, and the `executor` account only +after making and verifying any required backup. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index a77694871..dc9d6c011 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -106,14 +106,20 @@ expect(span.span.tags["executor.tool.outcome"]).toBe("fail"); ```sh cd e2e -bun run test # boots both dev servers, runs everything -bun run test:cloud # one target +bun run test # runs the prepared Rust local-selfhost binary +bun run test:legacy:cloud # archived cloud target, explicit opt-in bun run ports # print THIS checkout's derived ports # attach to an already-running server while iterating (use `bun run ports` URLs): -E2E_CLOUD_URL=http://127.0.0.1: ../node_modules/.bin/vitest run --project cloud -E2E_SELFHOST_URL=http://localhost: ../node_modules/.bin/vitest run --project selfhost +E2E_CLOUD_URL=http://127.0.0.1: ../node_modules/.bin/vitest run --config vitest.legacy.config.ts --project cloud +E2E_SELFHOST_URL=http://localhost: ../node_modules/.bin/vitest run --config vitest.legacy.config.ts --project selfhost ``` +From the repository root, `bun run test:e2e` is the complete active command: +it builds the Svelte assets, compiles the Rust binary with those assets, and +runs only the `local-selfhost` project. Do not use bare `vitest` for the active +suite. Archived projects live in `vitest.legacy.config.ts` and are reachable +only through the explicit legacy scripts. + Ports are claimed at boot (see `src/ports.ts`): each checkout hashes its repo root to a preferred block, atomically locks it (a held lock port makes races impossible), and walks to the next free block if it's locked or squatted — so diff --git a/e2e/cloud/auth-session.test.ts b/e2e/cloud/auth-session.test.ts index 7d5e96be9..da9707484 100644 --- a/e2e/cloud/auth-session.test.ts +++ b/e2e/cloud/auth-session.test.ts @@ -19,7 +19,7 @@ const setCookieFor = (response: Response, name: string): string => { }; // state = base64url(JSON { nonce, returnTo? }) — the app's login-state -// envelope (apps/cloud/src/auth/login-state.ts). +// envelope (legacy/cloud/src/auth/login-state.ts). const decodeLoginState = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ nonce: Schema.String, returnTo: Schema.optional(Schema.String) }), diff --git a/e2e/cloud/mcp-client-sessions.test.ts b/e2e/cloud/mcp-client-sessions.test.ts index 43f44eec6..b4eb29f40 100644 --- a/e2e/cloud/mcp-client-sessions.test.ts +++ b/e2e/cloud/mcp-client-sessions.test.ts @@ -4,7 +4,7 @@ // session continuity here is real Durable Object state surviving across // client connections, not a stub. // -// Ported from apps/cloud/src/mcp-miniflare.e2e.node.test.ts (unstable_dev + +// Ported from legacy/cloud/src/mcp-miniflare.e2e.node.test.ts (unstable_dev + // test-seam bearers) onto the e2e dev server with real OAuth bearers. // Telemetry-span assertions from that file required injecting an OTLP // receiver into the worker env and were NOT carried (not black-box). diff --git a/e2e/cloud/mcp-protocol.test.ts b/e2e/cloud/mcp-protocol.test.ts index df81e067d..edff45420 100644 --- a/e2e/cloud/mcp-protocol.test.ts +++ b/e2e/cloud/mcp-protocol.test.ts @@ -5,7 +5,7 @@ // real McpSessionDO, and real bearers minted from the authorization server the // product itself advertises (discovery → DCR → authorize → token). // -// Ported from apps/cloud/src/mcp-flow.test.ts (workerd-pool SELF.fetch with +// Ported from legacy/cloud/src/mcp-flow.test.ts (workerd-pool SELF.fetch with // test-seam bearers). DO-internal coverage from that file (forced runtime // eviction, idle-alarm firing, alarm scheduling, storage seeding) is clock / // internals dependent and intentionally NOT carried — only black-box diff --git a/e2e/cloud/oauth-connections.test.ts b/e2e/cloud/oauth-connections.test.ts index ff0a9c4d8..6d02c5261 100644 --- a/e2e/cloud/oauth-connections.test.ts +++ b/e2e/cloud/oauth-connections.test.ts @@ -5,7 +5,7 @@ // product API while a real OAuth authorization server runs inside the scenario // on 127.0.0.1 (the dev server exchanges the code against it directly). // -// Ported from apps/cloud/src/mcp/mcp-oauth.node.test.ts, extended to cover +// Ported from legacy/cloud/src/mcp/mcp-oauth.node.test.ts, extended to cover // `complete` (the original stopped at the redirect). import { randomBytes } from "node:crypto"; diff --git a/e2e/cloud/sources-api.test.ts b/e2e/cloud/sources-api.test.ts index 785fe3d09..f43365287 100644 --- a/e2e/cloud/sources-api.test.ts +++ b/e2e/cloud/sources-api.test.ts @@ -6,7 +6,7 @@ // execution proves the whole chain: catalog row → connection → stamped tool → // QuickJS execution → live upstream request. // -// Ported from apps/cloud/src/api/sources-api.node.test.ts. Cross-user +// Ported from legacy/cloud/src/api/sources-api.node.test.ts. Cross-user // isolation of personal connections (alice/bob in one org) is NOT covered // here: minting a second member of an existing org has no public API surface. import { randomBytes } from "node:crypto"; diff --git a/e2e/cloud/sources-refresh.test.ts b/e2e/cloud/sources-refresh.test.ts index fe2b70afd..2a736a291 100644 --- a/e2e/cloud/sources-refresh.test.ts +++ b/e2e/cloud/sources-refresh.test.ts @@ -5,7 +5,7 @@ // whether the catalog row can be refreshed at all: a spec registered from a // URL can be re-fetched, a pasted blob cannot. // -// Ported from apps/cloud/src/api/sources-refresh.node.test.ts. The upstream +// Ported from legacy/cloud/src/api/sources-refresh.node.test.ts. The upstream // MCP server is a real HTTP server started inside the scenario on 127.0.0.1. import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; diff --git a/e2e/cloud/unauthenticated-skeleton.test.ts b/e2e/cloud/unauthenticated-skeleton.test.ts index 77005df21..b940ccd21 100644 --- a/e2e/cloud/unauthenticated-skeleton.test.ts +++ b/e2e/cloud/unauthenticated-skeleton.test.ts @@ -4,7 +4,7 @@ // AuthGate used to SSR the AUTHENTICATED app-shell skeleton (sidebar + card // grid) for every visitor and only swap to a login page after a client-side // `/account/me` 401 — signed-out users were shown an app they'd never reach. -// Now the SSR auth gate (apps/cloud/src/auth/ssr-gate.ts) verifies the sealed +// Now the SSR auth gate (legacy/cloud/src/auth/ssr-gate.ts) verifies the sealed // session cookie in the worker and 302s signed-out document requests to // /login (carrying ?returnTo=), so the app shell never exists for them. import { expect } from "@effect/vitest"; diff --git a/e2e/desktop/local-auth-mcp.test.ts b/e2e/desktop/local-auth-mcp.test.ts index a0ca14ee5..99f3919ca 100644 --- a/e2e/desktop/local-auth-mcp.test.ts +++ b/e2e/desktop/local-auth-mcp.test.ts @@ -33,7 +33,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { scenario } from "../src/scenario"; import { RunDir } from "../src/services"; -const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const appDir = fileURLToPath(new URL("../../legacy/desktop/", import.meta.url)); const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; diff --git a/e2e/desktop/reset-state.test.ts b/e2e/desktop/reset-state.test.ts index 534b28401..f778ab5a7 100644 --- a/e2e/desktop/reset-state.test.ts +++ b/e2e/desktop/reset-state.test.ts @@ -19,7 +19,7 @@ import { _electron } from "playwright"; import { scenario } from "../src/scenario"; import { RunDir } from "../src/services"; -const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const appDir = fileURLToPath(new URL("../../legacy/desktop/", import.meta.url)); const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; const CORRUPT_MARKER = "executor-e2e-corrupted-db"; diff --git a/e2e/desktop/sidecar-crash-screen.test.ts b/e2e/desktop/sidecar-crash-screen.test.ts index 66433064c..9872f42ec 100644 --- a/e2e/desktop/sidecar-crash-screen.test.ts +++ b/e2e/desktop/sidecar-crash-screen.test.ts @@ -20,10 +20,10 @@ import { _electron } from "playwright"; import { scenario } from "../src/scenario"; import { RunDir } from "../src/services"; -const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const appDir = fileURLToPath(new URL("../../legacy/desktop/", import.meta.url)); // require("electron") resolves to the binary path (electron's index.js -// exports it) — resolved from apps/desktop so we get the app's pinned +// exports it), resolved from legacy/desktop so we get the app's pinned // version out of the workspace store. const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; diff --git a/e2e/local-selfhost/core-journeys.test.ts b/e2e/local-selfhost/core-journeys.test.ts new file mode 100644 index 000000000..d96530299 --- /dev/null +++ b/e2e/local-selfhost/core-journeys.test.ts @@ -0,0 +1,625 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { expect } from "@effect/vitest"; +import { createEmulator, type Emulator } from "@executor-js/emulate"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + makeGreetingGraphqlSchema, + serveGraphqlTestServer, +} from "@executor-js/plugin-graphql/testing"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { serveOpenApiEchoTestServer } from "@executor-js/plugin-openapi/testing"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { LocalAdminClient, type LocalSource } from "../src/local-admin"; +import { serveOAuthTestProvider } from "../src/oauth-test-provider"; +import { Browser, Target } from "../src/services"; +import { LOCAL_ADMIN, LOCAL_SETUP_BASE_URL } from "../targets/local-selfhost"; + +const unique = (prefix: string) => `${prefix}-${randomBytes(4).toString("hex")}`; +const executeFile = promisify(execFile); + +const adminClient = (baseUrl: string) => + Effect.promise(() => + LocalAdminClient.signIn(baseUrl, LOCAL_ADMIN.username, LOCAL_ADMIN.password), + ); + +const resendEmulator = Effect.acquireRelease( + Effect.promise(() => createEmulator({ service: "resend" })), + (emulator: Emulator) => Effect.promise(() => emulator.close()).pipe(Effect.ignore), +); + +const deleteSources = (client: LocalAdminClient, sources: readonly LocalSource[]) => + Effect.forEach(sources, (source) => + Effect.promise(() => client.deleteSource(source.id)).pipe(Effect.ignore), + ).pipe(Effect.asVoid); + +scenario( + "First boot · the one-time setup link creates the only administrator", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const setupToken = process.env.E2E_LOCAL_SETUP_TOKEN; + if (!setupToken) return yield* Effect.die("the fresh setup instance did not publish a token"); + + yield* browser.privateSession({ label: "first-boot administrator" }, async ({ page, step }) => { + await step("Open the one-time setup link and create the administrator", async () => { + await page.goto(`${LOCAL_SETUP_BASE_URL}/setup#token=${encodeURIComponent(setupToken)}`); + await expect.poll(() => new URL(page.url()).hash).toBe(""); + await page.getByLabel("Administrator username").fill("first-boot-admin"); + await page.getByLabel("Password", { exact: true }).fill("first-boot-password-123"); + await page.getByLabel("Confirm password").fill("first-boot-password-123"); + await page.getByRole("button", { name: "Create administrator" }).click(); + await page.waitForURL((url) => url.pathname === "/sources"); + await expect(page.getByRole("heading", { name: "Sources" })).toBeVisible(); + }); + }); + const replay = yield* Effect.promise(() => + fetch(new URL("/api/v1/setup", LOCAL_SETUP_BASE_URL), { + method: "POST", + headers: { + "content-type": "application/json", + origin: new URL(LOCAL_SETUP_BASE_URL).origin, + }, + body: JSON.stringify({ + setupToken, + username: "second-admin", + password: "second-admin-password-123", + }), + }), + ); + expect(replay.status, "the consumed setup token cannot create another administrator").toBe(409); + }), +); + +scenario( + "Administrator access · sign-in creates a gateway token without exposing it twice", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + + yield* browser.privateSession({ label: "signed-out administrator" }, async ({ page, step }) => { + await step("Sign in and create a token, then dismiss the one-time secret", async () => { + await page.goto("/login"); + await page.getByLabel("Username").fill(LOCAL_ADMIN.username); + await page.getByLabel("Password").fill(LOCAL_ADMIN.password); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => url.pathname === "/sources"); + await page.goto("/tokens"); + await page.getByLabel("Token name").fill("E2E local agent"); + await page.getByRole("button", { name: "Create token" }).click(); + const secret = await page.getByLabel("API token").inputValue(); + expect(secret, "the token is revealed exactly once").toMatch(/^exr_/); + await page.getByRole("button", { name: "I saved it" }).click(); + await expect(page.getByLabel("API token")).toHaveCount(0); + }); + }); + + const identity = yield* target.newIdentity(); + yield* browser.session(identity, async ({ page, step }) => { + await step("Return to the token list and see only masked metadata", async () => { + await page.goto("/tokens"); + const row = page.getByRole("row").filter({ hasText: "E2E local agent" }); + await expect(row).toContainText("Active"); + await expect(row.locator('td[data-label="Token"] code')).toContainText("..."); + }); + }); + }), +); + +scenario( + "Sources · OpenAPI, GraphQL, and MCP join one local catalog", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const client = yield* adminClient(target.baseUrl); + const resend = yield* resendEmulator; + const graphql = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema({ includeMutation: false }), + }); + const mcp = yield* serveMcpServer(() => + makeGreetingMcpServer({ name: unique("mcp"), text: "hello from local MCP" }), + ); + const suffix = randomBytes(3).toString("hex"); + const created: LocalSource[] = []; + + yield* Effect.ensuring( + Effect.gen(function* () { + created.push( + yield* Effect.promise(() => + client.createSource({ + kind: "openapi", + displayName: `Resend emulator ${suffix}`, + spec: { type: "url", url: resend.openapiUrl }, + allowPrivateNetwork: true, + }), + ), + ); + created.push( + yield* Effect.promise(() => + client.createSource({ + kind: "graphql", + displayName: `GraphQL greeting ${suffix}`, + endpoint: graphql.endpoint, + allowPrivateNetwork: true, + }), + ), + ); + created.push( + yield* Effect.promise(() => + client.createSource({ + kind: "mcp_http", + displayName: `MCP greeting ${suffix}`, + endpoint: mcp.endpoint, + allowPrivateNetwork: true, + }), + ), + ); + + const token = yield* Effect.promise(() => client.createToken(unique("source-agent"))); + for (const source of created.slice(1)) { + const catalog = yield* Effect.promise(() => client.listTools({ sourceId: source.id })); + expect( + catalog.items.length, + `${source.displayName} discovers at least one tool`, + ).toBeGreaterThan(0); + const enabled = yield* Effect.promise(() => + client.setToolMode(catalog.items[0]!, "enabled"), + ); + const argumentsForTool = source.id === created[1]?.id ? { name: "Ada" } : {}; + const invoked = yield* Effect.promise(() => + fetch(new URL("/api/v1/gateway/tools/invoke", target.baseUrl), { + method: "POST", + headers: { + authorization: `Bearer ${token.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ path: enabled.callablePath, arguments: argumentsForTool }), + }), + ); + expect(invoked.status, `${source.displayName} executes through the gateway`).toBe(200); + } + const graphqlRequests = yield* graphql.requests; + expect( + graphqlRequests.filter((request) => !request.payload.query?.includes("__schema")), + "the GraphQL resolver received a non-introspection call", + ).not.toHaveLength(0); + const mcpRequests = yield* mcp.requests; + expect( + mcpRequests.length, + "the MCP server received discovery and invocation traffic", + ).toBeGreaterThanOrEqual(2); + + const identity = yield* target.newIdentity(); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open Sources and see every supported protocol", async () => { + await page.goto("/sources"); + for (const source of created) { + await expect(page.getByText(source.displayName, { exact: true })).toBeVisible(); + } + await expect(page.getByText("OpenAPI", { exact: true }).last()).toBeVisible(); + await expect(page.getByText("GraphQL", { exact: true }).last()).toBeVisible(); + await expect(page.getByText("MCP HTTP", { exact: true }).last()).toBeVisible(); + }); + }); + }), + deleteSources(client, created), + ); + }), + ), +); + +scenario( + "Tools · search, filters, and broad mode changes stay explicit", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const client = yield* adminClient(target.baseUrl); + const upstream = yield* serveOpenApiEchoTestServer(); + const name = unique("Mode controls"); + const source = yield* Effect.promise(() => + client.createSource({ + kind: "openapi", + displayName: name, + preferredSlug: unique("modes").replaceAll("-", "_"), + spec: { type: "inline", content: upstream.specJson }, + allowPrivateNetwork: true, + }), + ); + const catalog = yield* Effect.promise(() => client.listTools({ sourceId: source.id })); + const tool = catalog.items[0]; + if (!tool) return yield* Effect.die("the mode fixture produced no tool"); + const token = yield* Effect.promise(() => client.createToken(unique("mode-agent"))); + const invoke = () => + fetch(new URL("/api/v1/gateway/tools/invoke", target.baseUrl), { + method: "POST", + headers: { + authorization: `Bearer ${token.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ path: tool.callablePath, arguments: { message: "Mode" } }), + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + const identity = yield* target.newIdentity(); + yield* browser.session(identity, async ({ page, step }) => { + await step("Search the global catalog and filter to one source", async () => { + await page.goto("/tools"); + await page.getByLabel("Search tools").fill("echo"); + await page.getByRole("button", { name: "Search" }).click(); + await page.getByLabel("Source").selectOption({ label: name }); + await expect(page.getByRole("table")).toContainText("echo"); + }); + + await step("Ask applies immediately to the selected tools", async () => { + await page.getByLabel("Select all active tools on this page").check(); + await page + .getByRole("group", { name: "Set selected tools" }) + .getByLabel("Ask") + .check(); + await page.getByRole("button", { name: /Set .* to Ask/ }).click(); + await expect(page.locator("#bulk-status")).toContainText("Ask applied"); + }); + + await step("Disabling many tools requires a second confirmation", async () => { + await page.getByLabel("Select all active tools on this page").check(); + await page + .getByRole("group", { name: "Set selected tools" }) + .getByLabel("Disabled") + .check(); + await page.getByRole("button", { name: /Set .* to Disabled/ }).click(); + await expect( + page.getByRole("group", { name: "Confirm bulk tool behavior" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Confirm Disabled" }).click(); + await expect(page.locator("#bulk-status")).toContainText("Disabled applied"); + const denied = await invoke(); + expect( + (await denied.json()) as unknown, + "disabled tools are denied at the gateway", + ).toMatchObject({ + error: { code: "tool_disabled" }, + }); + }); + + await step("Re-enabling many tools also requires deliberate confirmation", async () => { + await page.getByLabel("Effective mode").selectOption("disabled"); + await page.getByLabel("Select all active tools on this page").check(); + await page + .getByRole("group", { name: "Set selected tools" }) + .getByLabel("Enabled") + .check(); + await page.getByRole("button", { name: /Set .* to Enabled/ }).click(); + await page.getByRole("button", { name: "Confirm Enabled" }).click(); + await expect(page.locator("#bulk-status")).toContainText("Enabled applied"); + expect((await invoke()).status, "enabled tools are callable without approval").toBe( + 200, + ); + }); + }); + }), + Effect.promise(() => client.deleteSource(source.id)).pipe(Effect.ignore), + ); + }), + ), +); + +scenario( + "MCP and CLI · bearer clients share the global catalog and concurrent runtime", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const client = yield* adminClient(target.baseUrl); + const upstream = yield* serveOpenApiEchoTestServer(); + const source = yield* Effect.promise(() => + client.createSource({ + kind: "openapi", + displayName: unique("MCP CLI source"), + spec: { type: "inline", content: upstream.specJson }, + allowPrivateNetwork: true, + }), + ); + const catalog = yield* Effect.promise(() => client.listTools({ sourceId: source.id })); + const tool = catalog.items[0]; + if (!tool) return yield* Effect.die("the MCP and CLI fixture produced no tool"); + yield* Effect.promise(() => client.setToolMode(tool, "enabled")); + const token = yield* Effect.promise(() => client.createToken(unique("mcp-cli-agent"))); + const binary = process.env.E2E_EXECUTOR_BIN; + if (!binary) return yield* Effect.die("the e2e harness did not publish its Executor binary"); + const cliEnvironment = { ...process.env, EXECUTOR_API_TOKEN: token.token }; + const cli = (arguments_: readonly string[]) => + Effect.promise(() => + executeFile(binary, ["--base-url", target.baseUrl, "--json", ...arguments_], { + env: cliEnvironment, + }), + ); + + yield* Effect.ensuring( + Effect.gen(function* () { + const sources = yield* cli(["tools", "sources"]); + expect(sources.stdout, "the CLI lists the source catalog").toContain(source.slug); + const search = yield* cli(["tools", "search", "echo"]); + expect(search.stdout, "the CLI searches the same global tools").toContain( + tool.callablePath, + ); + const described = yield* cli(["tools", "describe", tool.callablePath]); + expect(described.stdout, "the CLI describes the selected tool").toContain( + tool.displayName, + ); + const called = yield* cli(["call", tool.callablePath, '{"message":"CLI"}']); + expect(called.stdout, "the CLI calls the enabled tool").toContain("CLI"); + + const httpMcp = yield* Effect.acquireRelease( + Effect.promise(async () => { + const mcp = new Client({ name: "executor-e2e-http", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(target.mcpUrl), { + requestInit: { headers: { authorization: `Bearer ${token.token}` } }, + }); + await mcp.connect(transport); + return mcp; + }), + (mcp) => Effect.promise(() => mcp.close()).pipe(Effect.ignore), + ); + const advertised = yield* Effect.promise(() => httpMcp.listTools()); + expect( + advertised.tools.map((candidate) => candidate.name), + "HTTP MCP advertises the bounded gateway surface", + ).toEqual(expect.arrayContaining(["execute", "call", "search", "describe", "sources"])); + const executed = yield* Effect.promise(() => + httpMcp.callTool({ + name: "execute", + arguments: { + code: `const values = await Promise.all([${tool.callablePath}({ message: "one" }), ${tool.callablePath}({ message: "two" })]); return values;`, + }, + }), + ); + expect( + JSON.stringify(executed), + "the TS runtime completes two concurrent calls", + ).toContain("one"); + expect(JSON.stringify(executed)).toContain("two"); + + const stdioMcp = yield* Effect.acquireRelease( + Effect.promise(async () => { + const mcp = new Client({ name: "executor-e2e-stdio", version: "1.0.0" }); + const transport = new StdioClientTransport({ + command: binary, + args: ["--base-url", target.baseUrl, "mcp"], + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + EXECUTOR_API_TOKEN: token.token, + }, + stderr: "pipe", + }); + await mcp.connect(transport); + return mcp; + }), + (mcp) => Effect.promise(() => mcp.close()).pipe(Effect.ignore), + ); + const bridged = yield* Effect.promise(() => stdioMcp.listTools()); + expect( + bridged.tools.map((candidate) => candidate.name), + "the CLI stdio bridge exposes the same MCP tools", + ).toEqual(advertised.tools.map((candidate) => candidate.name)); + + yield* Effect.promise(() => client.revokeToken(token.id)); + const afterRevocation = yield* Effect.exit(Effect.tryPromise(() => httpMcp.listTools())); + expect(afterRevocation._tag, "revocation invalidates an existing MCP session").toBe( + "Failure", + ); + }), + Effect.promise(() => client.deleteSource(source.id)).pipe(Effect.ignore), + ); + }), + ), +); + +scenario( + "Approvals and logs · an Ask call waits for one administrator decision", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const client = yield* adminClient(target.baseUrl); + const upstream = yield* serveOpenApiEchoTestServer(); + const source = yield* Effect.promise(() => + client.createSource({ + kind: "openapi", + displayName: unique("Approval source"), + spec: { type: "inline", content: upstream.specJson }, + allowPrivateNetwork: true, + }), + ); + + yield* Effect.ensuring( + Effect.gen(function* () { + const page = yield* Effect.promise(() => client.listTools({ sourceId: source.id })); + const tool = page.items[0]; + if (!tool) return yield* Effect.die("the approval fixture produced no tool"); + yield* Effect.promise(() => client.setToolMode(tool, "ask")); + const token = yield* Effect.promise(() => client.createToken(unique("approval-agent"))); + const invoke = yield* Effect.promise(() => + fetch(new URL("/api/v1/gateway/tools/invoke", target.baseUrl), { + method: "POST", + headers: { + authorization: `Bearer ${token.token}`, + "content-type": "application/json", + "idempotency-key": randomUUID(), + }, + body: JSON.stringify({ path: tool.callablePath, arguments: { message: "Ada" } }), + }), + ); + expect(invoke.status, "Ask returns an accepted approval handle").toBe(202); + const pending = (yield* Effect.promise(() => invoke.json())) as { + readonly approval: { readonly id: string; readonly statusUrl: string }; + }; + const approvalStatusUrl = new URL(pending.approval.statusUrl, target.baseUrl); + expect(approvalStatusUrl.origin, "the bearer stays on this Executor origin").toBe( + new URL(target.baseUrl).origin, + ); + expect(approvalStatusUrl.pathname, "the handle names this exact approval").toBe( + `/api/v1/gateway/approvals/${pending.approval.id}`, + ); + + const identity = yield* target.newIdentity(); + yield* browser.session(identity, async ({ page, step }) => { + await step("Review the exact pending request", async () => { + await page.goto(`/approvals?approval=${encodeURIComponent(pending.approval.id)}`); + await expect(page.getByText(tool.callablePath, { exact: true }).last()).toBeVisible(); + await expect(page.getByText("Structural, redacted argument preview")).toBeVisible(); + }); + await step("Approve this invocation once", async () => { + await page.getByRole("button", { name: "Approve once" }).click(); + await page.getByRole("button", { name: "Yes, approve once" }).click(); + await expect(page.getByText(/was approved/i)).toBeVisible(); + }); + await step("Open Logs and find the completed gateway request", async () => { + await page.goto("/logs"); + await expect( + page.getByText(tool.callablePath, { exact: true }).first(), + ).toBeVisible(); + }); + }); + + const completed = yield* Effect.promise(async () => { + for (let attempt = 0; attempt < 80; attempt += 1) { + const response = await fetch(approvalStatusUrl, { + headers: { authorization: `Bearer ${token.token}` }, + }); + const body = (await response.json()) as { readonly status: string }; + if (!["pending", "approved", "executing"].includes(body.status)) return body; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error("approved call did not finish"); + }); + expect(completed.status, "the approved invocation runs exactly once").toBe("succeeded"); + const requests = yield* upstream.requests; + expect( + requests.filter((request) => request.path.startsWith("/echo/")).length, + "one approval produces one upstream side effect", + ).toBe(1); + }), + Effect.promise(() => client.deleteSource(source.id)).pipe(Effect.ignore), + ); + }), + ), +); + +scenario( + "Managed OAuth · the provider callback connects a source without exposing tokens", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const client = yield* adminClient(target.baseUrl); + const provider = yield* serveOAuthTestProvider(); + const source = yield* Effect.promise(() => + client.createSource({ + kind: "mcp_http", + displayName: unique("Managed OAuth"), + allowPrivateNetwork: true, + endpoint: provider.endpoint, + }), + ); + + yield* Effect.ensuring( + Effect.gen(function* () { + const available = yield* Effect.promise(() => client.getOAuthConnections(source.id)); + const credentialKey = available.availableCredentials[0]?.credentialKey; + if (!credentialKey) return yield* Effect.die("the OAuth source exposed no credential"); + const placeholder = yield* Effect.promise(() => + client.putOAuthConnection(source.id, credentialKey, { + expectedRevision: 0, + discovery: { type: "issuer", issuer: provider.issuer }, + client: { clientId: "pending-dynamic-registration", authentication: "none" }, + scopes: ["repo", "read:user"], + }), + ); + const clientId = yield* Effect.promise(() => + provider.registerClient(placeholder.callbackUrl), + ); + const connection = yield* Effect.promise(() => + client.putOAuthConnection(source.id, credentialKey, { + expectedRevision: placeholder.revision, + discovery: { type: "issuer", issuer: provider.issuer }, + client: { clientId, authentication: "none" }, + scopes: ["repo", "read:user"], + }), + ); + expect(connection.callbackUrl, "the callback is connection-specific").toContain( + "/api/v1/oauth/callback/", + ); + const authorization = yield* Effect.promise(() => + client.authorizeOAuthConnection(source.id, credentialKey, connection.revision), + ); + const identity = yield* target.newIdentity(); + yield* browser.privateSession(identity, async ({ page, step }) => { + await step( + "Follow the provider authorization back to this exact connection", + async () => { + await page.goto(authorization.authorizationUrl); + await page.getByRole("button", { name: /admin/i }).click(); + await page.waitForURL( + (url) => url.pathname === "/sources" && url.searchParams.has("oauth"), + ); + await expect(page.getByText("OAuth authorization completed")).toBeVisible(); + await expect(page.getByText("Connected", { exact: true }).last()).toBeVisible(); + }, + ); + }); + const after = yield* Effect.promise(() => client.getOAuthConnections(source.id)); + expect(after.connections[0]?.status, "the callback exchanged the code").toBe("connected"); + yield* Effect.promise(() => client.refreshSource(source.id)); + yield* browser.session(identity, async ({ page, step }) => { + await step("Return to Sources and see the managed connection online", async () => { + await page.goto("/sources"); + await expect(page.getByText(source.displayName, { exact: true })).toBeVisible(); + await expect(page.getByText("Connected", { exact: true }).last()).toBeVisible(); + }); + }); + const tools = yield* Effect.promise(() => client.listTools({ sourceId: source.id })); + const tool = tools.items.find((candidate) => candidate.stableKey.endsWith("get_me")); + if (!tool) return yield* Effect.die("the managed OAuth source produced no tool"); + const enabled = yield* Effect.promise(() => client.setToolMode(tool, "enabled")); + const token = yield* Effect.promise(() => client.createToken(unique("oauth-agent"))); + const callsBefore = (yield* Effect.promise(() => provider.ledger())).filter( + (entry) => entry.path === "/mcp", + ).length; + const invoked = yield* Effect.promise(() => + fetch(new URL("/api/v1/gateway/tools/invoke", target.baseUrl), { + method: "POST", + headers: { + authorization: `Bearer ${token.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ path: enabled.callablePath, arguments: {} }), + }), + ); + expect(invoked.status, "the connected OAuth tool is callable").toBe(200); + expect( + (yield* Effect.promise(() => provider.ledger())).filter( + (entry) => entry.path === "/mcp" && entry.response.status === 200, + ).length, + "the emulator ledger records an authenticated MCP tool call", + ).toBeGreaterThan(callsBefore); + }), + Effect.promise(() => client.deleteSource(source.id)).pipe(Effect.ignore), + ); + }), + ), +); diff --git a/e2e/package.json b/e2e/package.json index 7dac07bf0..1854130f7 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,18 +5,20 @@ "type": "module", "scripts": { "cli": "bun scripts/cli.ts", - "test": "vitest run --project cloud && vitest run --project selfhost", - "test:cloud": "vitest run --project cloud", - "test:selfhost": "vitest run --project selfhost", - "test:selfhost-docker": "vitest run --project selfhost-docker", - "test:cloudflare": "vitest run --project cloudflare", - "test:watch": "vitest", + "test": "vitest run --project local-selfhost", + "test:local": "vitest run --project local-selfhost", + "test:unit": "vitest run --config vitest.unit.config.ts", + "test:legacy:cloud": "vitest run --config vitest.legacy.config.ts --project cloud", + "test:legacy:selfhost": "vitest run --config vitest.legacy.config.ts --project selfhost", + "test:legacy:selfhost-docker": "vitest run --config vitest.legacy.config.ts --project selfhost-docker", + "test:legacy:cloudflare": "vitest run --config vitest.legacy.config.ts --project cloudflare", + "test:watch": "vitest --project local-selfhost", "ports": "bun scripts/ports.ts", "summary": "bun scripts/summary.ts", "viewer:build": "bun scripts/rebuild-viewer.ts", "serve": "bun scripts/rebuild-viewer.ts && bun scripts/serve.ts", "typecheck": "tsc --noEmit", - "test:desktop": "vitest run --project desktop", + "test:legacy:desktop": "vitest run --config vitest.legacy.config.ts --project desktop", "motel": "MOTEL_OTEL_BASE_URL=http://127.0.0.1:4796 MOTEL_OTEL_DB_PATH=runs/.motel/telemetry.sqlite motel server" }, "dependencies": { diff --git a/e2e/scripts/ports.ts b/e2e/scripts/ports.ts index 1724a41d9..5a3d61428 100644 --- a/e2e/scripts/ports.ts +++ b/e2e/scripts/ports.ts @@ -10,6 +10,7 @@ import { WORKOS_EMULATOR_PORT, } from "../targets/cloud"; import { SELFHOST_PORT } from "../targets/selfhost"; +import { LOCAL_SELFHOST_PORT, LOCAL_SETUP_PORT } from "../targets/local-selfhost"; import { repoRoot } from "../src/ports"; console.log(`preferred e2e ports for ${repoRoot}`); @@ -18,3 +19,5 @@ console.log(` cloud dev-db ${CLOUD_DB_PORT}`); console.log(` workos emulator ${WORKOS_EMULATOR_PORT}`); console.log(` autumn emulator ${AUTUMN_EMULATOR_PORT}`); console.log(` selfhost http://localhost:${SELFHOST_PORT}`); +console.log(` local executor http://127.0.0.1:${LOCAL_SELFHOST_PORT}`); +console.log(` first boot http://127.0.0.1:${LOCAL_SETUP_PORT}`); diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 24e7cd9c9..b5c072c6f 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -12,7 +12,7 @@ import { createEmulator } from "@executor-js/emulate"; import { bootProcesses, waitForHttp } from "./boot"; -export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); +export const cloudDir = fileURLToPath(new URL("../../legacy/cloud/", import.meta.url)); export interface CloudBootOptions { readonly cloudPort: number; diff --git a/e2e/setup/desktop-packaged.globalsetup.ts b/e2e/setup/desktop-packaged.globalsetup.ts index 14a0f3b7a..acef3452c 100644 --- a/e2e/setup/desktop-packaged.globalsetup.ts +++ b/e2e/setup/desktop-packaged.globalsetup.ts @@ -16,7 +16,7 @@ import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; -const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const appDir = fileURLToPath(new URL("../../legacy/desktop/", import.meta.url)); // (launch exe, bundled executor binary) inside the packaged bundle, per platform. const bundlePaths = (): { exe: string; executor: string } => { diff --git a/e2e/setup/desktop.globalsetup.ts b/e2e/setup/desktop.globalsetup.ts index ab2d74a2d..4108e1c1d 100644 --- a/e2e/setup/desktop.globalsetup.ts +++ b/e2e/setup/desktop.globalsetup.ts @@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); -const appsDesktop = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const appsDesktop = fileURLToPath(new URL("../../legacy/desktop/", import.meta.url)); export default function setup() { execFileSync("bun", ["run", "--filter", "@executor-js/local", "build"], { diff --git a/e2e/setup/local-selfhost.globalsetup.ts b/e2e/setup/local-selfhost.globalsetup.ts new file mode 100644 index 000000000..31db479d8 --- /dev/null +++ b/e2e/setup/local-selfhost.globalsetup.ts @@ -0,0 +1,140 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { claimPorts, repoRoot } from "../src/ports"; +import { LOCAL_ADMIN } from "../targets/local-selfhost"; +import { waitForHttp } from "./boot"; + +interface BootedExecutor { + readonly child: ChildProcess; + readonly dataDir: string; + readonly setupToken: string; +} + +const stop = async (process: ChildProcess) => { + if (process.pid === undefined || process.exitCode !== null) return; + const exited = new Promise((resolveExit) => process.once("exit", () => resolveExit())); + try { + globalThis.process.kill(-process.pid, "SIGTERM"); + } catch { + process.kill("SIGTERM"); + } + const graceful = await Promise.race([ + exited.then(() => true), + new Promise((resolveGrace) => setTimeout(() => resolveGrace(false), 5_000)), + ]); + if (!graceful) { + try { + globalThis.process.kill(-process.pid, "SIGKILL"); + } catch { + process.kill("SIGKILL"); + } + await Promise.race([exited, new Promise((resolveWait) => setTimeout(resolveWait, 2_000))]); + } +}; + +const bootExecutor = async ( + binary: string, + port: number, + label: string, +): Promise => { + const dataDir = mkdtempSync(`${tmpdir()}/executor-e2e-${label}-`); + const child = spawn( + binary, + [ + "server", + "--bind", + `127.0.0.1:${port}`, + "--data-dir", + dataDir, + "--public-origin", + `http://127.0.0.1:${port}`, + ], + { cwd: repoRoot, detached: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + let output = ""; + let collectingSetupLink = true; + const capture = (chunk: Buffer) => { + if (!collectingSetupLink) return; + output = `${output}${chunk.toString("utf8")}`.slice(-64 * 1024); + }; + child.stdout?.on("data", capture); + child.stderr?.on("data", capture); + + try { + await waitForHttp(`http://127.0.0.1:${port}/api/v1/bootstrap`, { timeoutMs: 30_000 }); + const deadline = Date.now() + 10_000; + while (!output.match(/\/setup#token=([^\s]+)/) && Date.now() < deadline) { + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + const setupToken = output.match(/\/setup#token=([^\s]+)/)?.[1]; + if (!setupToken) throw new Error(`could not read setup token from ${label} server output`); + collectingSetupLink = false; + output = ""; + return { child, dataDir, setupToken }; + } catch (error) { + await stop(child); + rmSync(dataDir, { recursive: true, force: true }); + throw error; + } +}; + +const completeSetup = async (baseUrl: string, setupToken: string) => { + const response = await fetch(new URL("/api/v1/setup", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, + body: JSON.stringify({ setupToken, ...LOCAL_ADMIN }), + }); + if (!response.ok) { + throw new Error(`could not prepare local selfhost administrator (${response.status})`); + } +}; + +export default async function setup(): Promise<() => Promise> { + if (process.env.E2E_LOCAL_SELFHOST_URL || process.env.E2E_LOCAL_SETUP_URL) { + throw new Error( + "The complete local e2e suite needs two fresh instances. Run without E2E_LOCAL_SELFHOST_URL and E2E_LOCAL_SETUP_URL.", + ); + } + const binary = resolve( + process.env.E2E_EXECUTOR_BIN ?? resolve(repoRoot, "target/debug/executor"), + ); + if (!existsSync(binary)) { + throw new Error( + `Executor binary not found at ${binary}. Run the root test:e2e command so the Svelte assets and Rust binary are prepared first.`, + ); + } + process.env.E2E_EXECUTOR_BIN = binary; + + const { ports, release } = await claimPorts([ + { envVar: "E2E_LOCAL_SELFHOST_PORT", offset: 4, label: "Rust local selfhost" }, + { envVar: "E2E_LOCAL_SETUP_PORT", offset: 5, label: "Rust first-boot setup" }, + ]); + const mainPort = ports.E2E_LOCAL_SELFHOST_PORT!; + const setupPort = ports.E2E_LOCAL_SETUP_PORT!; + const main = await bootExecutor(binary, mainPort, "main"); + let firstBoot: BootedExecutor | undefined; + try { + await completeSetup(`http://127.0.0.1:${mainPort}`, main.setupToken); + firstBoot = await bootExecutor(binary, setupPort, "setup"); + process.env.E2E_LOCAL_SELFHOST_URL = `http://127.0.0.1:${mainPort}`; + process.env.E2E_LOCAL_SETUP_URL = `http://127.0.0.1:${setupPort}`; + process.env.E2E_LOCAL_SETUP_TOKEN = firstBoot.setupToken; + } catch (error) { + await stop(main.child); + if (firstBoot) await stop(firstBoot.child); + rmSync(main.dataDir, { recursive: true, force: true }); + if (firstBoot) rmSync(firstBoot.dataDir, { recursive: true, force: true }); + await release(); + throw error; + } + + return async () => { + await Promise.all([stop(main.child), stop(firstBoot.child)]); + rmSync(main.dataDir, { recursive: true, force: true }); + rmSync(firstBoot.dataDir, { recursive: true, force: true }); + await release(); + }; +} diff --git a/e2e/src/local-admin.ts b/e2e/src/local-admin.ts new file mode 100644 index 000000000..0f7073117 --- /dev/null +++ b/e2e/src/local-admin.ts @@ -0,0 +1,151 @@ +export interface LocalTool { + readonly id: string; + readonly sourceId: string; + readonly stableKey: string; + readonly displayName: string; + readonly callablePath: string; + readonly revision: number; + readonly effectiveMode: { readonly mode: "enabled" | "ask" | "disabled" }; +} + +export interface LocalSource { + readonly id: string; + readonly slug: string; + readonly displayName: string; + readonly revision: number; + readonly toolCount: number; +} + +export interface LocalToken { + readonly id: string; + readonly token: string; +} + +export class LocalAdminClient { + readonly #origin: string; + readonly #cookie: string; + readonly #csrf: string; + + private constructor(origin: string, cookie: string, csrf: string) { + this.#origin = origin; + this.#cookie = cookie; + this.#csrf = csrf; + } + + static async signIn(origin: string, username: string, password: string) { + const response = await fetch(new URL("/api/v1/session", origin), { + method: "POST", + headers: { "content-type": "application/json", origin: new URL(origin).origin }, + body: JSON.stringify({ username, password }), + }); + if (!response.ok) throw new Error(`administrator sign-in failed (${response.status})`); + const pairs = response.headers + .getSetCookie() + .map((cookie) => cookie.split(";", 1)[0]?.trim()) + .filter((cookie): cookie is string => Boolean(cookie)); + const csrf = pairs + .find((cookie) => cookie.startsWith("executor_csrf=")) + ?.slice("executor_csrf=".length); + if (pairs.length === 0 || !csrf) + throw new Error("administrator sign-in returned no CSRF cookie"); + return new LocalAdminClient(origin, pairs.join("; "), csrf); + } + + async createToken(name: string) { + return this.#json("/api/v1/tokens", { method: "POST", body: { name } }); + } + + async revokeToken(tokenId: string) { + await this.#request(`/api/v1/tokens/${encodeURIComponent(tokenId)}`, { method: "DELETE" }); + } + + async createSource(input: unknown) { + return this.#json("/api/v1/sources", { method: "POST", body: input }); + } + + async deleteSource(sourceId: string) { + await this.#request(`/api/v1/sources/${encodeURIComponent(sourceId)}`, { method: "DELETE" }); + } + + async refreshSource(sourceId: string) { + await this.#request(`/api/v1/sources/${encodeURIComponent(sourceId)}/refresh`, { + method: "POST", + body: {}, + }); + } + + async listTools(query: { readonly sourceId?: string; readonly q?: string } = {}) { + const parameters = new URLSearchParams({ limit: "100" }); + if (query.sourceId) parameters.set("sourceId", query.sourceId); + if (query.q) parameters.set("query", query.q); + return this.#json<{ readonly items: readonly LocalTool[]; readonly catalogRevision: number }>( + `/api/v1/tools?${parameters}`, + ); + } + + async setToolMode(tool: LocalTool, mode: "enabled" | "ask" | "disabled" | null) { + return this.#json(`/api/v1/tools/${encodeURIComponent(tool.id)}/mode`, { + method: "PATCH", + body: { mode, expectedRevision: tool.revision }, + }); + } + + async putOAuthConnection(sourceId: string, credentialKey: string, input: unknown) { + return this.#json<{ readonly revision: number; readonly callbackUrl: string }>( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}`, + { method: "PUT", body: input }, + ); + } + + async authorizeOAuthConnection( + sourceId: string, + credentialKey: string, + expectedRevision: number, + ) { + return this.#json<{ readonly authorizationUrl: string }>( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}/authorize`, + { method: "POST", body: { expectedRevision } }, + ); + } + + async getOAuthConnections(sourceId: string) { + return this.#json<{ + readonly connections: ReadonlyArray<{ + readonly credentialKey: string; + readonly status: string; + readonly callbackUrl: string; + }>; + readonly availableCredentials: ReadonlyArray<{ readonly credentialKey: string }>; + }>(`/api/v1/sources/${encodeURIComponent(sourceId)}/oauth`); + } + + async #json( + path: string, + options: { readonly method?: string; readonly body?: unknown } = {}, + ) { + const response = await this.#request(path, options); + return (await response.json()) as Value; + } + + async #request( + path: string, + options: { readonly method?: string; readonly body?: unknown } = {}, + ) { + const method = options.method ?? "GET"; + const headers = new Headers({ accept: "application/json", cookie: this.#cookie }); + if (options.body !== undefined) headers.set("content-type", "application/json"); + if (!["GET", "HEAD", "OPTIONS"].includes(method)) { + headers.set("origin", new URL(this.#origin).origin); + headers.set("x-executor-csrf", this.#csrf); + } + const response = await fetch(new URL(path, this.#origin), { + method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + if (!response.ok) { + throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); + } + return response; + } +} diff --git a/e2e/src/oauth-test-provider.test.ts b/e2e/src/oauth-test-provider.test.ts new file mode 100644 index 000000000..6baf39898 --- /dev/null +++ b/e2e/src/oauth-test-provider.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { serveOAuthTestProvider } from "./oauth-test-provider"; + +it.effect("uses the MCP emulator's RFC 8414 and dynamic client surfaces", () => + Effect.scoped( + Effect.gen(function* () { + const provider = yield* serveOAuthTestProvider(); + const metadata = yield* Effect.promise(() => + fetch(`${provider.issuer}/.well-known/oauth-authorization-server`).then((response) => + response.json(), + ), + ); + expect(metadata).toMatchObject({ + issuer: provider.issuer, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }); + + const clientId = yield* Effect.promise(() => + provider.registerClient("http://127.0.0.1/callback"), + ); + expect(clientId).toMatch(/^mcp-client-/); + expect((yield* Effect.promise(() => provider.ledger())).map((entry) => entry.path)).toContain( + "/register", + ); + }), + ), +); diff --git a/e2e/src/oauth-test-provider.ts b/e2e/src/oauth-test-provider.ts new file mode 100644 index 000000000..c3664cd8f --- /dev/null +++ b/e2e/src/oauth-test-provider.ts @@ -0,0 +1,46 @@ +import { createEmulator, type Emulator, type LedgerEntry } from "@executor-js/emulate"; +import { Effect } from "effect"; + +export interface OAuthTestProvider { + readonly issuer: string; + readonly endpoint: string; + readonly registerClient: (redirectUri: string) => Promise; + readonly ledger: () => Promise>; +} + +export const serveOAuthTestProvider = () => + Effect.acquireRelease( + Effect.promise(async (): Promise<{ provider: OAuthTestProvider; emulator: Emulator }> => { + const emulator = await createEmulator({ service: "mcp" }); + return { + emulator, + provider: { + issuer: emulator.url, + endpoint: `${emulator.url}/mcp`, + registerClient: async (redirectUri) => { + const response = await fetch(`${emulator.url}/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "Executor local e2e", + redirect_uris: [redirectUri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }); + if (!response.ok) { + throw new Error(`MCP emulator client registration failed (${response.status})`); + } + const registration = (await response.json()) as { readonly client_id?: string }; + if (!registration.client_id) { + throw new Error("MCP emulator client registration returned no client_id"); + } + return registration.client_id; + }, + ledger: () => emulator.ledger.list(), + }, + }; + }), + ({ emulator }) => Effect.promise(() => emulator.close()).pipe(Effect.ignore), + ).pipe(Effect.map(({ provider }) => provider)); diff --git a/e2e/src/scenario-redaction.test.ts b/e2e/src/scenario-redaction.test.ts new file mode 100644 index 000000000..d3ed0e608 --- /dev/null +++ b/e2e/src/scenario-redaction.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "@effect/vitest"; + +import { redactFailureText } from "./scenario"; + +it("removes setup, API, cookie, CSRF, and password credentials from persisted failures", () => { + const secret = [ + "http://127.0.0.1/setup#token=setup-secret", + "Authorization: Bearer bearer-secret", + "exr_token-secret", + "Cookie: executor_session=session-secret; executor_csrf=csrf-secret", + "x-executor-csrf: csrf-secret", + '{"password":"password-secret","setupToken":"setup-secret"}', + "http://127.0.0.1/callback?code=code-secret&state=state-secret&code_verifier=verifier-secret&code_challenge=challenge-secret", + '{"access_token":"access-secret","refresh_token":"refresh-secret","clientSecret":"client-secret","apiToken":"api-secret"}', + ].join("\n"); + const redacted = redactFailureText(secret); + for (const value of [ + "setup-secret", + "bearer-secret", + "token-secret", + "session-secret", + "csrf-secret", + "password-secret", + "code-secret", + "state-secret", + "verifier-secret", + "challenge-secret", + "access-secret", + "refresh-secret", + "client-secret", + "api-secret", + ]) { + expect(redacted).not.toContain(value); + } + expect(redacted).toContain("[REDACTED]"); +}); diff --git a/e2e/src/scenario.ts b/e2e/src/scenario.ts index cb233c237..cfbb3cdd6 100644 --- a/e2e/src/scenario.ts +++ b/e2e/src/scenario.ts @@ -208,10 +208,22 @@ const missingServices = (cause: Cause.Cause): ReadonlyArray => }; const failureMessage = (cause: Cause.Cause): string => { - const rendered = String(Cause.squash(cause)); + const rendered = redactFailureText(String(Cause.squash(cause))); return rendered.length > 2_000 ? `${rendered.slice(0, 2_000)}…` : rendered; }; +export const redactFailureText = (text: string): string => + text + .replace(/(#token=)[^\s"'<>]+/giu, "$1[REDACTED]") + .replace(/([?&](?:code|state|code_verifier|code_challenge)=)[^&#\s"'<>]+/giu, "$1[REDACTED]") + .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/giu, "$1[REDACTED]") + .replace(/\bex[er]_[A-Za-z0-9._~-]+\b/gu, "exr_[REDACTED]") + .replace( + /("(?:password|setupToken|csrfToken|token|apiToken|access_token|refresh_token|clientSecret|client_secret|code_verifier|code_challenge)"\s*:\s*")[^"]*(")/giu, + "$1[REDACTED]$2", + ) + .replace(/\b(cookie|set-cookie|x-executor-csrf):\s*[^\r\n]+/giu, "$1: [REDACTED]"); + /** The *.test.ts file that called scenario(), from the registration stack. */ const captureTestFile = (): string | undefined => { const stack = new Error().stack ?? ""; diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index 2cc24903e..43742165c 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -26,6 +26,15 @@ export interface BrowserSurface { identity: Identity, drive: (session: BrowserSession) => Promise, ) => Effect.Effect; + /** + * Drive credential entry without persisting network bodies, DOM snapshots, + * or video. Step screenshots are still written after each action, so callers + * must leave the page in a secret-free state before a step returns. + */ + readonly privateSession: ( + identity: Identity, + drive: (session: BrowserSession) => Promise, + ) => Effect.Effect; } const slug = (text: string): string => @@ -37,8 +46,12 @@ const slug = (text: string): string => // acquireUseRelease so a vitest timeout (fiber interruption) still closes the // browser and flushes video + trace — a bare promise would leak Chromium. -export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface => ({ - session: (identity, drive) => +export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface => { + const session = ( + identity: Identity, + drive: (session: BrowserSession) => Promise, + record: boolean, + ) => Effect.acquireUseRelease( Effect.promise(async () => { const videoTmp = join(dir, ".video-tmp"); @@ -66,14 +79,16 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface const context = await browser.newContext({ colorScheme: "dark", viewport: { width: 1280, height: 800 }, - recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, + ...(record ? { recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } } } : {}), baseURL: target.baseUrl, }); - await context.tracing.start({ - screenshots: true, - snapshots: true, - sources: true, - }); + if (record) { + await context.tracing.start({ + screenshots: true, + snapshots: true, + sources: true, + }); + } if (identity.cookies?.length) { await context.addCookies( identity.cookies.map((cookie) => ({ @@ -85,13 +100,15 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface const page = await context.newPage(); // The session video's clock starts with the page; anchor it for the // run's focus timeline (scripts/film.ts cuts on these). - markRecordingStart(dir, "browser"); + if (record) markRecordingStart(dir, "browser"); // Main-frame navigations feed the viewer's synthetic URL bar — the // recording itself is chromeless, so this is the only place the // address the developer "typed" survives. - page.on("framenavigated", (frame) => { - if (frame === page.mainFrame()) markNavigation(dir, frame.url()); - }); + if (record) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) markNavigation(dir, frame.url()); + }); + } // Harvest distributed-trace ids: every app API request carries a W3C // traceparent (Effect's HttpClient), and each id names one // click→server→DB trace in whatever OTLP store the run exported to @@ -145,11 +162,11 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface // filming, enterFocus lingers a beat on whatever the developer was // looking at before tabbing here. await enterFocus(dir, "browser"); - await context.tracing.group(label); + if (record) await context.tracing.group(label); try { await action(page); } finally { - await context.tracing.groupEnd(); + if (record) await context.tracing.groupEnd(); } await page.screenshot({ path: join(dir, `${String(shots.count++).padStart(2, "0")}-${slug(label)}.png`), @@ -162,14 +179,18 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface await drive({ page, step }); } catch (error) { // Freeze the scene: the artifact dir shows the screen at failure. - await page.screenshot({ path: join(dir, "failure.png") }).catch(() => {}); + if (record) { + await page.screenshot({ path: join(dir, "failure.png") }).catch(() => {}); + } throw error; } }), ({ browser, context, page, videoTmp, traceIds }) => Effect.promise(async () => { appendTraces(dir, traceIds); - await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {}); + if (record) { + await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {}); + } const video = page.video(); await context.close(); // flushes the recording await browser.close(); @@ -199,5 +220,10 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface } rmSync(videoTmp, { recursive: true, force: true }); }), - ), -}); + ); + + return { + session: (identity, drive) => session(identity, drive, true), + privateSession: (identity, drive) => session(identity, drive, false), + }; +}; diff --git a/e2e/targets/local-selfhost.ts b/e2e/targets/local-selfhost.ts new file mode 100644 index 000000000..ff4fc72e5 --- /dev/null +++ b/e2e/targets/local-selfhost.ts @@ -0,0 +1,53 @@ +import { Effect } from "effect"; + +import { e2ePort } from "../src/ports"; +import type { Identity, Target } from "../src/target"; + +export const LOCAL_SELFHOST_PORT = e2ePort("E2E_LOCAL_SELFHOST_PORT", 4); +export const LOCAL_SETUP_PORT = e2ePort("E2E_LOCAL_SETUP_PORT", 5); +export const LOCAL_SELFHOST_BASE_URL = + process.env.E2E_LOCAL_SELFHOST_URL ?? `http://127.0.0.1:${LOCAL_SELFHOST_PORT}`; +export const LOCAL_SETUP_BASE_URL = + process.env.E2E_LOCAL_SETUP_URL ?? `http://127.0.0.1:${LOCAL_SETUP_PORT}`; + +export const LOCAL_ADMIN = { + username: process.env.E2E_LOCAL_ADMIN_USERNAME ?? "admin", + password: process.env.E2E_LOCAL_ADMIN_PASSWORD ?? "executor-e2e-admin-password", +}; + +const cookiePairs = (headers: Headers) => + (headers.getSetCookie?.() ?? []) + .map((cookie) => cookie.split(";", 1)[0]?.trim()) + .filter((cookie): cookie is string => Boolean(cookie)); + +export const signInLocalAdmin = async ( + baseUrl: string, + credentials = LOCAL_ADMIN, +): Promise => { + const response = await fetch(new URL("/api/v1/session", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, + body: JSON.stringify(credentials), + redirect: "manual", + }); + if (!response.ok) throw new Error(`local selfhost sign-in failed (${response.status})`); + const pairs = cookiePairs(response.headers); + if (pairs.length === 0) throw new Error("local selfhost sign-in returned no cookies"); + return { + label: credentials.username, + credentials: { email: credentials.username, password: credentials.password }, + headers: { cookie: pairs.join("; ") }, + cookies: pairs.map((pair) => { + const separator = pair.indexOf("="); + return { name: pair.slice(0, separator), value: pair.slice(separator + 1) }; + }), + }; +}; + +export const localSelfhostTarget = (): Target => ({ + name: "local-selfhost", + baseUrl: LOCAL_SELFHOST_BASE_URL, + mcpUrl: `${LOCAL_SELFHOST_BASE_URL}/mcp`, + capabilities: new Set(["browser"]), + newIdentity: () => Effect.promise(() => signInLocalAdmin(LOCAL_SELFHOST_BASE_URL)), +}); diff --git a/e2e/targets/registry.ts b/e2e/targets/registry.ts index 94e966746..1074a4088 100644 --- a/e2e/targets/registry.ts +++ b/e2e/targets/registry.ts @@ -7,6 +7,7 @@ import { cloudTarget } from "./cloud"; import { cloudflareTarget } from "./cloudflare"; import { desktopTarget } from "./desktop"; import { localTarget } from "./local"; +import { localSelfhostTarget } from "./local-selfhost"; import { selfhostTarget } from "./selfhost"; import { selfhostDockerTarget } from "./selfhost-docker"; @@ -20,6 +21,7 @@ const factories: Record Target> = { // `desktop` — no standard surfaces to carry. See desktop-packaged.globalsetup. "desktop-packaged": desktopTarget, local: localTarget, + "local-selfhost": localSelfhostTarget, // The supervised CLI daemon inside a VM, one project per guest OS — restart() // is a real reboot. See setup/cli.globalsetup.ts. "cli-macos": cliTarget, diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 6e3d5a56b..83bb5a22e 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,108 +1,19 @@ import { defineConfig } from "vitest/config"; -// One project per target. Same scenario files, different running instance: -// `vitest run --project cloud` / `--project selfhost` (or both, the default). -// Each project's globalsetup boots that app's OWN dev server (or attaches to -// E2E__URL). Scenarios are isolated by fresh identities, not resets. -const project = (name: string, overrides: Record = {}) => ({ - test: { - name, - include: ["scenarios/**/*.test.ts", `${name}/**/*.test.ts`], - env: { E2E_TARGET: name }, - globalSetup: [`./setup/${name}.globalsetup.ts`], - testTimeout: 180_000, - hookTimeout: 120_000, - ...overrides, - }, -}); - export default defineConfig({ test: { projects: [ - // PGlite's socket server is effectively single-connection; parallel test - // files (each fanning out per-request postgres sockets) crash it. Run - // files serially — swap PGlite for real Postgres if wall-clock matters. - project("cloud", { fileParallelism: false }), - // selfhost identities are the shared bootstrap admin for now — run files - // serially until per-test invite-signup isolation lands. - project("selfhost", { fileParallelism: false }), - // The same app as the PRODUCTION Docker artifact (the image users - // deploy: production build, bun serve.ts, /data volume) instead of the - // dev server. Runs the cross-target scenarios AND the selfhost/** - // scenarios — it is the same single-tenant app, so they all apply. - // Needs a docker daemon with host-networking support (Engine ≥ 26 on - // Docker Desktop); not part of the default `npm run test` chain — run - // with `npm run test:selfhost-docker` (release gate + CI for the - // publish workflow). - project("selfhost-docker", { - include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"], - fileParallelism: false, - }), - // The Cloudflare self-host worker (workerd via wrangler dev, dev-auth). - // Scoped to the cross-target scenarios wired for this host; the rest of - // scenarios/** is not yet validated against the worker. The full-graph - // scenario is included on purpose: workerd's 128MB isolate is the exact - // limit the streaming compile + content-addressed serve path defends, so - // it is the one host where that regression must be proven. Shares - // self-host's single-admin model. - project("cloudflare", { - include: [ - "scenarios/browser-approval.test.ts", - "scenarios/microsoft-graph-full.test.ts", - "cloudflare/**/*.test.ts", - ], - fileParallelism: false, - }), - // The Electron desktop app. Only desktop/** scenarios — the desktop - // target provides none of the standard surfaces (each scenario - // launches its own app via Playwright's electron driver), so running - // the cross-target suite here would just emit a page of skips. Needs - // a display; not part of the default `npm run test` chain. - project("desktop", { - include: ["desktop/**/*.test.ts"], - fileParallelism: false, - testTimeout: 300_000, - }), - // The PACKAGED desktop app: the real electron-builder bundle, where - // app.isPackaged is true — the ONLY target that exercises the supervised- - // daemon attach path (ensureSupervisedConnection) and the bundled executor. - // Its globalsetup builds the bundle (slow), so it's separate from - // `desktop` to keep the fast dev-electron suite off the package build. - // Needs a display; not part of the default `npm run test` chain — run with - // `vitest run --project desktop-packaged`. - project("desktop-packaged", { - include: ["desktop-packaged/**/*.test.ts"], - fileParallelism: false, - testTimeout: 360_000, - hookTimeout: 600_000, - }), - // The single-user local app. Each scenario launches its OWN `executor - // web` via the CLI on a throwaway data dir + an OS-assigned port, so - // there is no shared instance and scenarios are independent — file - // parallelism is ON. No globalSetup (nothing shared to boot). Only - // local/** scenarios. Not part of the default `npm run test` chain; run - // with `vitest run --project local`. - project("local", { - include: ["local/**/*.test.ts"], - globalSetup: [], - fileParallelism: true, - testTimeout: 180_000, - }), - // The supervised CLI daemon inside a guest VM, one project per OS. The - // globalsetup provisions a VM, `executor service install`s the daemon, and - // tunnels it; restart() reboots the guest for REAL, so restart-persistence - // proves the boot-time auto-start path. Needs tart (macOS/Linux) or an EC2 - // credential (Windows); not part of the default `npm run test` chain — run - // with `vitest run --project cli-macos` (etc.) on the Mini. - ...(["macos", "linux", "windows"] as const).map((os) => - project(`cli-${os}`, { - include: ["scenarios/restart-persistence.test.ts", "cli/**/*.test.ts"], - env: { E2E_TARGET: `cli-${os}`, E2E_VM_OS: os }, + { + test: { + name: "local-selfhost", + include: ["local-selfhost/**/*.test.ts"], + env: { E2E_TARGET: "local-selfhost" }, + globalSetup: ["./setup/local-selfhost.globalsetup.ts"], fileParallelism: false, - testTimeout: 300_000, - hookTimeout: 900_000, - }), - ), + testTimeout: 180_000, + hookTimeout: 120_000, + }, + }, ], }, }); diff --git a/e2e/vitest.legacy.config.ts b/e2e/vitest.legacy.config.ts new file mode 100644 index 000000000..1f78dfc24 --- /dev/null +++ b/e2e/vitest.legacy.config.ts @@ -0,0 +1,59 @@ +import { defineConfig } from "vitest/config"; + +const project = (name: string, overrides: Record = {}) => ({ + test: { + name, + include: ["scenarios/**/*.test.ts", `${name}/**/*.test.ts`], + env: { E2E_TARGET: name }, + globalSetup: [`./setup/${name}.globalsetup.ts`], + testTimeout: 180_000, + hookTimeout: 120_000, + ...overrides, + }, +}); + +export default defineConfig({ + test: { + projects: [ + project("cloud", { fileParallelism: false }), + project("selfhost", { fileParallelism: false }), + project("selfhost-docker", { + include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"], + fileParallelism: false, + }), + project("cloudflare", { + include: [ + "scenarios/browser-approval.test.ts", + "scenarios/microsoft-graph-full.test.ts", + "cloudflare/**/*.test.ts", + ], + fileParallelism: false, + }), + project("desktop", { + include: ["desktop/**/*.test.ts"], + fileParallelism: false, + testTimeout: 300_000, + }), + project("desktop-packaged", { + include: ["desktop-packaged/**/*.test.ts"], + fileParallelism: false, + testTimeout: 360_000, + hookTimeout: 600_000, + }), + project("local", { + include: ["local/**/*.test.ts"], + globalSetup: [], + fileParallelism: true, + }), + ...(["macos", "linux"] as const).map((os) => + project(`cli-${os}`, { + include: ["scenarios/restart-persistence.test.ts", "cli/**/*.test.ts"], + env: { E2E_TARGET: `cli-${os}`, E2E_VM_OS: os }, + fileParallelism: false, + testTimeout: 300_000, + hookTimeout: 900_000, + }), + ), + ], + }, +}); diff --git a/apps/desktop/vitest.config.ts b/e2e/vitest.unit.config.ts similarity index 100% rename from apps/desktop/vitest.config.ts rename to e2e/vitest.unit.config.ts diff --git a/knip.config.ts b/knip.config.ts index b823e3b4f..1612cb5f7 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -7,10 +7,10 @@ const config: KnipConfig = { "apps/local": { entry: ["src/server.ts", "src/routes/**/*.tsx", "src/server/*.ts"], }, - "apps/cloud": { + "legacy/cloud": { entry: ["src/server.ts", "src/routes/**/*.tsx", "src/server/*.ts"], }, - "apps/desktop": { + "legacy/desktop": { entry: ["src/preload.ts"], }, "apps/marketing": { diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 000000000..6a65fc250 --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,17 @@ +# Legacy products + +This directory contains the previous hosted cloud and Electron desktop product +entry points. The active product is the local/self-hosted Rust server and +Svelte dashboard in the repository root and `web/`. + +The legacy packages remain in the Bun workspace so their dependency graph and +historical end-to-end scenarios stay reproducible. They are excluded from the +default development, test, typecheck, lint, format, and CI paths. Use the root +`legacy:dev`, `legacy:test`, or `legacy:typecheck` scripts when working on them +explicitly. They are not the foundation for new product work. + +- `cloud/`: the previous managed Cloudflare deployment +- `desktop/`: the previous Electron desktop deployment + +The shared TypeScript packages under `packages/` remain outside this directory. +They are retained independently from these deployment entry points. diff --git a/apps/cloud/.gitignore b/legacy/cloud/.gitignore similarity index 100% rename from apps/cloud/.gitignore rename to legacy/cloud/.gitignore diff --git a/apps/cloud/CHANGELOG.md b/legacy/cloud/CHANGELOG.md similarity index 100% rename from apps/cloud/CHANGELOG.md rename to legacy/cloud/CHANGELOG.md diff --git a/apps/cloud/drizzle.config.ts b/legacy/cloud/drizzle.config.ts similarity index 100% rename from apps/cloud/drizzle.config.ts rename to legacy/cloud/drizzle.config.ts diff --git a/apps/cloud/drizzle/0000_v2_baseline.sql b/legacy/cloud/drizzle/0000_v2_baseline.sql similarity index 100% rename from apps/cloud/drizzle/0000_v2_baseline.sql rename to legacy/cloud/drizzle/0000_v2_baseline.sql diff --git a/apps/cloud/drizzle/0001_illegal_wolverine.sql b/legacy/cloud/drizzle/0001_illegal_wolverine.sql similarity index 100% rename from apps/cloud/drizzle/0001_illegal_wolverine.sql rename to legacy/cloud/drizzle/0001_illegal_wolverine.sql diff --git a/apps/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql b/legacy/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql similarity index 100% rename from apps/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql rename to legacy/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql diff --git a/apps/cloud/drizzle/0003_friendly_monster_badoon.sql b/legacy/cloud/drizzle/0003_friendly_monster_badoon.sql similarity index 100% rename from apps/cloud/drizzle/0003_friendly_monster_badoon.sql rename to legacy/cloud/drizzle/0003_friendly_monster_badoon.sql diff --git a/apps/cloud/drizzle/0004_nervous_quasar.sql b/legacy/cloud/drizzle/0004_nervous_quasar.sql similarity index 100% rename from apps/cloud/drizzle/0004_nervous_quasar.sql rename to legacy/cloud/drizzle/0004_nervous_quasar.sql diff --git a/apps/cloud/drizzle/0005_integration_descriptions.sql b/legacy/cloud/drizzle/0005_integration_descriptions.sql similarity index 100% rename from apps/cloud/drizzle/0005_integration_descriptions.sql rename to legacy/cloud/drizzle/0005_integration_descriptions.sql diff --git a/apps/cloud/drizzle/0006_google_openapi_ownership.sql b/legacy/cloud/drizzle/0006_google_openapi_ownership.sql similarity index 100% rename from apps/cloud/drizzle/0006_google_openapi_ownership.sql rename to legacy/cloud/drizzle/0006_google_openapi_ownership.sql diff --git a/apps/cloud/drizzle/0007_red_dragon_lord.sql b/legacy/cloud/drizzle/0007_red_dragon_lord.sql similarity index 100% rename from apps/cloud/drizzle/0007_red_dragon_lord.sql rename to legacy/cloud/drizzle/0007_red_dragon_lord.sql diff --git a/apps/cloud/drizzle/meta/0000_snapshot.json b/legacy/cloud/drizzle/meta/0000_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0000_snapshot.json rename to legacy/cloud/drizzle/meta/0000_snapshot.json diff --git a/apps/cloud/drizzle/meta/0001_snapshot.json b/legacy/cloud/drizzle/meta/0001_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0001_snapshot.json rename to legacy/cloud/drizzle/meta/0001_snapshot.json diff --git a/apps/cloud/drizzle/meta/0002_snapshot.json b/legacy/cloud/drizzle/meta/0002_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0002_snapshot.json rename to legacy/cloud/drizzle/meta/0002_snapshot.json diff --git a/apps/cloud/drizzle/meta/0003_snapshot.json b/legacy/cloud/drizzle/meta/0003_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0003_snapshot.json rename to legacy/cloud/drizzle/meta/0003_snapshot.json diff --git a/apps/cloud/drizzle/meta/0004_snapshot.json b/legacy/cloud/drizzle/meta/0004_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0004_snapshot.json rename to legacy/cloud/drizzle/meta/0004_snapshot.json diff --git a/apps/cloud/drizzle/meta/0005_snapshot.json b/legacy/cloud/drizzle/meta/0005_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0005_snapshot.json rename to legacy/cloud/drizzle/meta/0005_snapshot.json diff --git a/apps/cloud/drizzle/meta/0006_snapshot.json b/legacy/cloud/drizzle/meta/0006_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0006_snapshot.json rename to legacy/cloud/drizzle/meta/0006_snapshot.json diff --git a/apps/cloud/drizzle/meta/0007_snapshot.json b/legacy/cloud/drizzle/meta/0007_snapshot.json similarity index 100% rename from apps/cloud/drizzle/meta/0007_snapshot.json rename to legacy/cloud/drizzle/meta/0007_snapshot.json diff --git a/apps/cloud/drizzle/meta/_journal.json b/legacy/cloud/drizzle/meta/_journal.json similarity index 100% rename from apps/cloud/drizzle/meta/_journal.json rename to legacy/cloud/drizzle/meta/_journal.json diff --git a/apps/cloud/executor.config.ts b/legacy/cloud/executor.config.ts similarity index 100% rename from apps/cloud/executor.config.ts rename to legacy/cloud/executor.config.ts diff --git a/apps/cloud/package.json b/legacy/cloud/package.json similarity index 100% rename from apps/cloud/package.json rename to legacy/cloud/package.json diff --git a/apps/cloud/public/apple-touch-icon.png b/legacy/cloud/public/apple-touch-icon.png similarity index 100% rename from apps/cloud/public/apple-touch-icon.png rename to legacy/cloud/public/apple-touch-icon.png diff --git a/apps/cloud/public/favicon-192.png b/legacy/cloud/public/favicon-192.png similarity index 100% rename from apps/cloud/public/favicon-192.png rename to legacy/cloud/public/favicon-192.png diff --git a/apps/cloud/public/favicon-32.png b/legacy/cloud/public/favicon-32.png similarity index 100% rename from apps/cloud/public/favicon-32.png rename to legacy/cloud/public/favicon-32.png diff --git a/apps/cloud/public/favicon.ico b/legacy/cloud/public/favicon.ico similarity index 100% rename from apps/cloud/public/favicon.ico rename to legacy/cloud/public/favicon.ico diff --git a/apps/cloud/scripts/backfill-org-slugs.ts b/legacy/cloud/scripts/backfill-org-slugs.ts similarity index 100% rename from apps/cloud/scripts/backfill-org-slugs.ts rename to legacy/cloud/scripts/backfill-org-slugs.ts diff --git a/apps/cloud/scripts/build.mjs b/legacy/cloud/scripts/build.mjs similarity index 100% rename from apps/cloud/scripts/build.mjs rename to legacy/cloud/scripts/build.mjs diff --git a/apps/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts b/legacy/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts similarity index 100% rename from apps/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts rename to legacy/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts diff --git a/apps/cloud/scripts/code-migrations/index.ts b/legacy/cloud/scripts/code-migrations/index.ts similarity index 100% rename from apps/cloud/scripts/code-migrations/index.ts rename to legacy/cloud/scripts/code-migrations/index.ts diff --git a/apps/cloud/scripts/code-migrations/runner.ts b/legacy/cloud/scripts/code-migrations/runner.ts similarity index 100% rename from apps/cloud/scripts/code-migrations/runner.ts rename to legacy/cloud/scripts/code-migrations/runner.ts diff --git a/apps/cloud/scripts/dev-db.ts b/legacy/cloud/scripts/dev-db.ts similarity index 100% rename from apps/cloud/scripts/dev-db.ts rename to legacy/cloud/scripts/dev-db.ts diff --git a/apps/cloud/scripts/gen-routes.ts b/legacy/cloud/scripts/gen-routes.ts similarity index 95% rename from apps/cloud/scripts/gen-routes.ts rename to legacy/cloud/scripts/gen-routes.ts index 60fc3ff14..34df80650 100644 --- a/apps/cloud/scripts/gen-routes.ts +++ b/legacy/cloud/scripts/gen-routes.ts @@ -27,4 +27,4 @@ await generateRouteTree({ "}", ], }); -console.log("generated apps/cloud route tree"); +console.log("generated legacy/cloud route tree"); diff --git a/apps/cloud/scripts/migrate-auth-configs.ts b/legacy/cloud/scripts/migrate-auth-configs.ts similarity index 100% rename from apps/cloud/scripts/migrate-auth-configs.ts rename to legacy/cloud/scripts/migrate-auth-configs.ts diff --git a/apps/cloud/scripts/migrate-specs-to-blobs.ts b/legacy/cloud/scripts/migrate-specs-to-blobs.ts similarity index 100% rename from apps/cloud/scripts/migrate-specs-to-blobs.ts rename to legacy/cloud/scripts/migrate-specs-to-blobs.ts diff --git a/apps/cloud/scripts/migrate.ts b/legacy/cloud/scripts/migrate.ts similarity index 100% rename from apps/cloud/scripts/migrate.ts rename to legacy/cloud/scripts/migrate.ts diff --git a/apps/cloud/scripts/repartition-vault-metadata.ts b/legacy/cloud/scripts/repartition-vault-metadata.ts similarity index 100% rename from apps/cloud/scripts/repartition-vault-metadata.ts rename to legacy/cloud/scripts/repartition-vault-metadata.ts diff --git a/apps/cloud/scripts/test-globalsetup.ts b/legacy/cloud/scripts/test-globalsetup.ts similarity index 100% rename from apps/cloud/scripts/test-globalsetup.ts rename to legacy/cloud/scripts/test-globalsetup.ts diff --git a/apps/cloud/src/account/account-api.ts b/legacy/cloud/src/account/account-api.ts similarity index 100% rename from apps/cloud/src/account/account-api.ts rename to legacy/cloud/src/account/account-api.ts diff --git a/apps/cloud/src/account/member-limits.node.test.ts b/legacy/cloud/src/account/member-limits.node.test.ts similarity index 100% rename from apps/cloud/src/account/member-limits.node.test.ts rename to legacy/cloud/src/account/member-limits.node.test.ts diff --git a/apps/cloud/src/account/organization-limits.node.test.ts b/legacy/cloud/src/account/organization-limits.node.test.ts similarity index 100% rename from apps/cloud/src/account/organization-limits.node.test.ts rename to legacy/cloud/src/account/organization-limits.node.test.ts diff --git a/apps/cloud/src/account/workos-account-service.ts b/legacy/cloud/src/account/workos-account-service.ts similarity index 100% rename from apps/cloud/src/account/workos-account-service.ts rename to legacy/cloud/src/account/workos-account-service.ts diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/legacy/cloud/src/api.request-scope.node.test.ts similarity index 100% rename from apps/cloud/src/api.request-scope.node.test.ts rename to legacy/cloud/src/api.request-scope.node.test.ts diff --git a/apps/cloud/src/api/error-response.ts b/legacy/cloud/src/api/error-response.ts similarity index 100% rename from apps/cloud/src/api/error-response.ts rename to legacy/cloud/src/api/error-response.ts diff --git a/apps/cloud/src/api/layers.ts b/legacy/cloud/src/api/layers.ts similarity index 100% rename from apps/cloud/src/api/layers.ts rename to legacy/cloud/src/api/layers.ts diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/legacy/cloud/src/api/protected-api-key-auth.node.test.ts similarity index 100% rename from apps/cloud/src/api/protected-api-key-auth.node.test.ts rename to legacy/cloud/src/api/protected-api-key-auth.node.test.ts diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/legacy/cloud/src/api/protected-jwt-auth.node.test.ts similarity index 100% rename from apps/cloud/src/api/protected-jwt-auth.node.test.ts rename to legacy/cloud/src/api/protected-jwt-auth.node.test.ts diff --git a/apps/cloud/src/api/protected.test.ts b/legacy/cloud/src/api/protected.test.ts similarity index 100% rename from apps/cloud/src/api/protected.test.ts rename to legacy/cloud/src/api/protected.test.ts diff --git a/apps/cloud/src/api/protected.ts b/legacy/cloud/src/api/protected.ts similarity index 98% rename from apps/cloud/src/api/protected.ts rename to legacy/cloud/src/api/protected.ts index 3d8a513bb..0a38f5815 100644 --- a/apps/cloud/src/api/protected.ts +++ b/legacy/cloud/src/api/protected.ts @@ -85,7 +85,7 @@ const ExecutionStackMiddleware = makeExecutionStackMiddleware< // collapses `requires: IdentityProvider | DbService | UserStoreService` to the // boot-only `WorkOSClient | ApiKeyService` (so `.layer` is a real Layer instead // of the "Need to combine" sentinel). Exposed as a factory so tests can swap in a -// counting fake — see `apps/cloud/src/api.request-scope.node.test.ts`. +// counting fake, see `legacy/cloud/src/api.request-scope.node.test.ts`. // // `AutumnService` is provided HERE — the billing service is scoped to the // executor plane that meters, not to the neutral boot core. (`/autumn`, the @@ -109,7 +109,7 @@ export const makeProtectedApiLive = (rsLive: Layer.Layer) => { const BillingRoutesLive = AutumnRoutesLive.pipe( Layer.provide(requestScopedMiddleware(requestScopedLive).layer), diff --git a/apps/cloud/src/app-paths.test.ts b/legacy/cloud/src/app-paths.test.ts similarity index 100% rename from apps/cloud/src/app-paths.test.ts rename to legacy/cloud/src/app-paths.test.ts diff --git a/apps/cloud/src/app-paths.ts b/legacy/cloud/src/app-paths.ts similarity index 100% rename from apps/cloud/src/app-paths.ts rename to legacy/cloud/src/app-paths.ts diff --git a/apps/cloud/src/app.ts b/legacy/cloud/src/app.ts similarity index 100% rename from apps/cloud/src/app.ts rename to legacy/cloud/src/app.ts diff --git a/apps/cloud/src/auth/api-jwt-bearer.ts b/legacy/cloud/src/auth/api-jwt-bearer.ts similarity index 100% rename from apps/cloud/src/auth/api-jwt-bearer.ts rename to legacy/cloud/src/auth/api-jwt-bearer.ts diff --git a/apps/cloud/src/auth/api-keys.node.test.ts b/legacy/cloud/src/auth/api-keys.node.test.ts similarity index 100% rename from apps/cloud/src/auth/api-keys.node.test.ts rename to legacy/cloud/src/auth/api-keys.node.test.ts diff --git a/apps/cloud/src/auth/api-keys.ts b/legacy/cloud/src/auth/api-keys.ts similarity index 100% rename from apps/cloud/src/auth/api-keys.ts rename to legacy/cloud/src/auth/api-keys.ts diff --git a/apps/cloud/src/auth/api.ts b/legacy/cloud/src/auth/api.ts similarity index 100% rename from apps/cloud/src/auth/api.ts rename to legacy/cloud/src/auth/api.ts diff --git a/apps/cloud/src/auth/bearer.ts b/legacy/cloud/src/auth/bearer.ts similarity index 100% rename from apps/cloud/src/auth/bearer.ts rename to legacy/cloud/src/auth/bearer.ts diff --git a/apps/cloud/src/auth/context.ts b/legacy/cloud/src/auth/context.ts similarity index 100% rename from apps/cloud/src/auth/context.ts rename to legacy/cloud/src/auth/context.ts diff --git a/apps/cloud/src/auth/cookies.ts b/legacy/cloud/src/auth/cookies.ts similarity index 100% rename from apps/cloud/src/auth/cookies.ts rename to legacy/cloud/src/auth/cookies.ts diff --git a/apps/cloud/src/auth/errors.ts b/legacy/cloud/src/auth/errors.ts similarity index 100% rename from apps/cloud/src/auth/errors.ts rename to legacy/cloud/src/auth/errors.ts diff --git a/apps/cloud/src/auth/handlers.ts b/legacy/cloud/src/auth/handlers.ts similarity index 100% rename from apps/cloud/src/auth/handlers.ts rename to legacy/cloud/src/auth/handlers.ts diff --git a/apps/cloud/src/auth/jwks-cache.node.test.ts b/legacy/cloud/src/auth/jwks-cache.node.test.ts similarity index 100% rename from apps/cloud/src/auth/jwks-cache.node.test.ts rename to legacy/cloud/src/auth/jwks-cache.node.test.ts diff --git a/apps/cloud/src/auth/jwks-cache.ts b/legacy/cloud/src/auth/jwks-cache.ts similarity index 100% rename from apps/cloud/src/auth/jwks-cache.ts rename to legacy/cloud/src/auth/jwks-cache.ts diff --git a/apps/cloud/src/auth/login-state.test.ts b/legacy/cloud/src/auth/login-state.test.ts similarity index 100% rename from apps/cloud/src/auth/login-state.test.ts rename to legacy/cloud/src/auth/login-state.test.ts diff --git a/apps/cloud/src/auth/login-state.ts b/legacy/cloud/src/auth/login-state.ts similarity index 100% rename from apps/cloud/src/auth/login-state.ts rename to legacy/cloud/src/auth/login-state.ts diff --git a/apps/cloud/src/auth/middleware-live.ts b/legacy/cloud/src/auth/middleware-live.ts similarity index 100% rename from apps/cloud/src/auth/middleware-live.ts rename to legacy/cloud/src/auth/middleware-live.ts diff --git a/apps/cloud/src/auth/middleware.ts b/legacy/cloud/src/auth/middleware.ts similarity index 100% rename from apps/cloud/src/auth/middleware.ts rename to legacy/cloud/src/auth/middleware.ts diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/legacy/cloud/src/auth/org-selector-auth.node.test.ts similarity index 100% rename from apps/cloud/src/auth/org-selector-auth.node.test.ts rename to legacy/cloud/src/auth/org-selector-auth.node.test.ts diff --git a/apps/cloud/src/auth/organization.ts b/legacy/cloud/src/auth/organization.ts similarity index 100% rename from apps/cloud/src/auth/organization.ts rename to legacy/cloud/src/auth/organization.ts diff --git a/apps/cloud/src/auth/request-origin.test.ts b/legacy/cloud/src/auth/request-origin.test.ts similarity index 100% rename from apps/cloud/src/auth/request-origin.test.ts rename to legacy/cloud/src/auth/request-origin.test.ts diff --git a/apps/cloud/src/auth/request-origin.ts b/legacy/cloud/src/auth/request-origin.ts similarity index 100% rename from apps/cloud/src/auth/request-origin.ts rename to legacy/cloud/src/auth/request-origin.ts diff --git a/apps/cloud/src/auth/return-to.test.ts b/legacy/cloud/src/auth/return-to.test.ts similarity index 100% rename from apps/cloud/src/auth/return-to.test.ts rename to legacy/cloud/src/auth/return-to.test.ts diff --git a/apps/cloud/src/auth/return-to.ts b/legacy/cloud/src/auth/return-to.ts similarity index 100% rename from apps/cloud/src/auth/return-to.ts rename to legacy/cloud/src/auth/return-to.ts diff --git a/apps/cloud/src/auth/route-paths.ts b/legacy/cloud/src/auth/route-paths.ts similarity index 100% rename from apps/cloud/src/auth/route-paths.ts rename to legacy/cloud/src/auth/route-paths.ts diff --git a/apps/cloud/src/auth/ssr-gate.ts b/legacy/cloud/src/auth/ssr-gate.ts similarity index 100% rename from apps/cloud/src/auth/ssr-gate.ts rename to legacy/cloud/src/auth/ssr-gate.ts diff --git a/apps/cloud/src/auth/user-store.ts b/legacy/cloud/src/auth/user-store.ts similarity index 100% rename from apps/cloud/src/auth/user-store.ts rename to legacy/cloud/src/auth/user-store.ts diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/legacy/cloud/src/auth/workos-auth-provider.ts similarity index 100% rename from apps/cloud/src/auth/workos-auth-provider.ts rename to legacy/cloud/src/auth/workos-auth-provider.ts diff --git a/apps/cloud/src/auth/workos.node.test.ts b/legacy/cloud/src/auth/workos.node.test.ts similarity index 100% rename from apps/cloud/src/auth/workos.node.test.ts rename to legacy/cloud/src/auth/workos.node.test.ts diff --git a/apps/cloud/src/auth/workos.ts b/legacy/cloud/src/auth/workos.ts similarity index 100% rename from apps/cloud/src/auth/workos.ts rename to legacy/cloud/src/auth/workos.ts diff --git a/apps/cloud/src/db/code-migration-runner.test.ts b/legacy/cloud/src/db/code-migration-runner.test.ts similarity index 100% rename from apps/cloud/src/db/code-migration-runner.test.ts rename to legacy/cloud/src/db/code-migration-runner.test.ts diff --git a/apps/cloud/src/db/db.schema.test.ts b/legacy/cloud/src/db/db.schema.test.ts similarity index 100% rename from apps/cloud/src/db/db.schema.test.ts rename to legacy/cloud/src/db/db.schema.test.ts diff --git a/apps/cloud/src/db/db.test.ts b/legacy/cloud/src/db/db.test.ts similarity index 100% rename from apps/cloud/src/db/db.test.ts rename to legacy/cloud/src/db/db.test.ts diff --git a/apps/cloud/src/db/db.ts b/legacy/cloud/src/db/db.ts similarity index 98% rename from apps/cloud/src/db/db.ts rename to legacy/cloud/src/db/db.ts index 219beaf38..b45bc2d2e 100644 --- a/apps/cloud/src/db/db.ts +++ b/legacy/cloud/src/db/db.ts @@ -25,7 +25,7 @@ import * as executorSchema from "./executor-schema"; // Exported so every drizzle() call in the cloud app shares one schema // object. Historically `mcp-session.ts` built its own and forgot to spread // `executorSchema`, producing runtime "unknown model source" errors that -// only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts. +// only surfaced in prod. See legacy/cloud/src/db/db.schema.test.ts. export const combinedSchema = { ...cloudSchema, ...executorSchema }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/cloud/src/db/executor-schema.ts b/legacy/cloud/src/db/executor-schema.ts similarity index 100% rename from apps/cloud/src/db/executor-schema.ts rename to legacy/cloud/src/db/executor-schema.ts diff --git a/apps/cloud/src/db/fuma.ts b/legacy/cloud/src/db/fuma.ts similarity index 100% rename from apps/cloud/src/db/fuma.ts rename to legacy/cloud/src/db/fuma.ts diff --git a/apps/cloud/src/db/google-openapi-r2-code-migration.test.ts b/legacy/cloud/src/db/google-openapi-r2-code-migration.test.ts similarity index 100% rename from apps/cloud/src/db/google-openapi-r2-code-migration.test.ts rename to legacy/cloud/src/db/google-openapi-r2-code-migration.test.ts diff --git a/apps/cloud/src/db/schema.ts b/legacy/cloud/src/db/schema.ts similarity index 100% rename from apps/cloud/src/db/schema.ts rename to legacy/cloud/src/db/schema.ts diff --git a/apps/cloud/src/edge/docs.test.ts b/legacy/cloud/src/edge/docs.test.ts similarity index 100% rename from apps/cloud/src/edge/docs.test.ts rename to legacy/cloud/src/edge/docs.test.ts diff --git a/apps/cloud/src/edge/docs.ts b/legacy/cloud/src/edge/docs.ts similarity index 100% rename from apps/cloud/src/edge/docs.ts rename to legacy/cloud/src/edge/docs.ts diff --git a/apps/cloud/src/edge/index.ts b/legacy/cloud/src/edge/index.ts similarity index 100% rename from apps/cloud/src/edge/index.ts rename to legacy/cloud/src/edge/index.ts diff --git a/apps/cloud/src/edge/marketing.ts b/legacy/cloud/src/edge/marketing.ts similarity index 100% rename from apps/cloud/src/edge/marketing.ts rename to legacy/cloud/src/edge/marketing.ts diff --git a/apps/cloud/src/edge/posthog.ts b/legacy/cloud/src/edge/posthog.ts similarity index 100% rename from apps/cloud/src/edge/posthog.ts rename to legacy/cloud/src/edge/posthog.ts diff --git a/apps/cloud/src/edge/sentry-tunnel.ts b/legacy/cloud/src/edge/sentry-tunnel.ts similarity index 100% rename from apps/cloud/src/edge/sentry-tunnel.ts rename to legacy/cloud/src/edge/sentry-tunnel.ts diff --git a/apps/cloud/src/engine/execution-stack-metered.ts b/legacy/cloud/src/engine/execution-stack-metered.ts similarity index 100% rename from apps/cloud/src/engine/execution-stack-metered.ts rename to legacy/cloud/src/engine/execution-stack-metered.ts diff --git a/apps/cloud/src/engine/execution-stack.ts b/legacy/cloud/src/engine/execution-stack.ts similarity index 100% rename from apps/cloud/src/engine/execution-stack.ts rename to legacy/cloud/src/engine/execution-stack.ts diff --git a/apps/cloud/src/engine/execution-usage.ts b/legacy/cloud/src/engine/execution-usage.ts similarity index 100% rename from apps/cloud/src/engine/execution-usage.ts rename to legacy/cloud/src/engine/execution-usage.ts diff --git a/apps/cloud/src/env-augment.d.ts b/legacy/cloud/src/env-augment.d.ts similarity index 100% rename from apps/cloud/src/env-augment.d.ts rename to legacy/cloud/src/env-augment.d.ts diff --git a/apps/cloud/src/extensions/billing/plans.ts b/legacy/cloud/src/extensions/billing/plans.ts similarity index 100% rename from apps/cloud/src/extensions/billing/plans.ts rename to legacy/cloud/src/extensions/billing/plans.ts diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/legacy/cloud/src/extensions/billing/route.node.test.ts similarity index 100% rename from apps/cloud/src/extensions/billing/route.node.test.ts rename to legacy/cloud/src/extensions/billing/route.node.test.ts diff --git a/apps/cloud/src/extensions/billing/route.ts b/legacy/cloud/src/extensions/billing/route.ts similarity index 100% rename from apps/cloud/src/extensions/billing/route.ts rename to legacy/cloud/src/extensions/billing/route.ts diff --git a/apps/cloud/src/extensions/billing/service.ts b/legacy/cloud/src/extensions/billing/service.ts similarity index 100% rename from apps/cloud/src/extensions/billing/service.ts rename to legacy/cloud/src/extensions/billing/service.ts diff --git a/apps/cloud/src/extensions/docs.ts b/legacy/cloud/src/extensions/docs.ts similarity index 100% rename from apps/cloud/src/extensions/docs.ts rename to legacy/cloud/src/extensions/docs.ts diff --git a/apps/cloud/src/extensions/routes.ts b/legacy/cloud/src/extensions/routes.ts similarity index 100% rename from apps/cloud/src/extensions/routes.ts rename to legacy/cloud/src/extensions/routes.ts diff --git a/apps/cloud/src/frontend-atom-error-capture.node.test.ts b/legacy/cloud/src/frontend-atom-error-capture.node.test.ts similarity index 100% rename from apps/cloud/src/frontend-atom-error-capture.node.test.ts rename to legacy/cloud/src/frontend-atom-error-capture.node.test.ts diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/legacy/cloud/src/mcp-session.e2e.node.test.ts similarity index 100% rename from apps/cloud/src/mcp-session.e2e.node.test.ts rename to legacy/cloud/src/mcp-session.e2e.node.test.ts diff --git a/apps/cloud/src/mcp/auth-provider.ts b/legacy/cloud/src/mcp/auth-provider.ts similarity index 100% rename from apps/cloud/src/mcp/auth-provider.ts rename to legacy/cloud/src/mcp/auth-provider.ts diff --git a/apps/cloud/src/mcp/auth.ts b/legacy/cloud/src/mcp/auth.ts similarity index 100% rename from apps/cloud/src/mcp/auth.ts rename to legacy/cloud/src/mcp/auth.ts diff --git a/apps/cloud/src/mcp/index.ts b/legacy/cloud/src/mcp/index.ts similarity index 100% rename from apps/cloud/src/mcp/index.ts rename to legacy/cloud/src/mcp/index.ts diff --git a/apps/cloud/src/mcp/jwt.ts b/legacy/cloud/src/mcp/jwt.ts similarity index 100% rename from apps/cloud/src/mcp/jwt.ts rename to legacy/cloud/src/mcp/jwt.ts diff --git a/apps/cloud/src/mcp/mcp-auth.node.test.ts b/legacy/cloud/src/mcp/mcp-auth.node.test.ts similarity index 100% rename from apps/cloud/src/mcp/mcp-auth.node.test.ts rename to legacy/cloud/src/mcp/mcp-auth.node.test.ts diff --git a/apps/cloud/src/mcp/mount.ts b/legacy/cloud/src/mcp/mount.ts similarity index 100% rename from apps/cloud/src/mcp/mount.ts rename to legacy/cloud/src/mcp/mount.ts diff --git a/apps/cloud/src/mcp/oauth-metadata.ts b/legacy/cloud/src/mcp/oauth-metadata.ts similarity index 100% rename from apps/cloud/src/mcp/oauth-metadata.ts rename to legacy/cloud/src/mcp/oauth-metadata.ts diff --git a/apps/cloud/src/mcp/reporter.ts b/legacy/cloud/src/mcp/reporter.ts similarity index 100% rename from apps/cloud/src/mcp/reporter.ts rename to legacy/cloud/src/mcp/reporter.ts diff --git a/apps/cloud/src/mcp/responses.ts b/legacy/cloud/src/mcp/responses.ts similarity index 100% rename from apps/cloud/src/mcp/responses.ts rename to legacy/cloud/src/mcp/responses.ts diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/legacy/cloud/src/mcp/session-durable-object.ts similarity index 100% rename from apps/cloud/src/mcp/session-durable-object.ts rename to legacy/cloud/src/mcp/session-durable-object.ts diff --git a/apps/cloud/src/mcp/session-store.ts b/legacy/cloud/src/mcp/session-store.ts similarity index 100% rename from apps/cloud/src/mcp/session-store.ts rename to legacy/cloud/src/mcp/session-store.ts diff --git a/apps/cloud/src/mcp/telemetry.ts b/legacy/cloud/src/mcp/telemetry.ts similarity index 100% rename from apps/cloud/src/mcp/telemetry.ts rename to legacy/cloud/src/mcp/telemetry.ts diff --git a/apps/cloud/src/mcp/worker-transport.test.ts b/legacy/cloud/src/mcp/worker-transport.test.ts similarity index 100% rename from apps/cloud/src/mcp/worker-transport.test.ts rename to legacy/cloud/src/mcp/worker-transport.test.ts diff --git a/apps/cloud/src/observability/browser-traces.test.ts b/legacy/cloud/src/observability/browser-traces.test.ts similarity index 100% rename from apps/cloud/src/observability/browser-traces.test.ts rename to legacy/cloud/src/observability/browser-traces.test.ts diff --git a/apps/cloud/src/observability/browser-traces.ts b/legacy/cloud/src/observability/browser-traces.ts similarity index 100% rename from apps/cloud/src/observability/browser-traces.ts rename to legacy/cloud/src/observability/browser-traces.ts diff --git a/apps/cloud/src/observability/error-logging.ts b/legacy/cloud/src/observability/error-logging.ts similarity index 100% rename from apps/cloud/src/observability/error-logging.ts rename to legacy/cloud/src/observability/error-logging.ts diff --git a/apps/cloud/src/observability/index.ts b/legacy/cloud/src/observability/index.ts similarity index 100% rename from apps/cloud/src/observability/index.ts rename to legacy/cloud/src/observability/index.ts diff --git a/apps/cloud/src/observability/observability.test.ts b/legacy/cloud/src/observability/observability.test.ts similarity index 100% rename from apps/cloud/src/observability/observability.test.ts rename to legacy/cloud/src/observability/observability.test.ts diff --git a/apps/cloud/src/observability/telemetry.ts b/legacy/cloud/src/observability/telemetry.ts similarity index 100% rename from apps/cloud/src/observability/telemetry.ts rename to legacy/cloud/src/observability/telemetry.ts diff --git a/apps/cloud/src/org/api.ts b/legacy/cloud/src/org/api.ts similarity index 100% rename from apps/cloud/src/org/api.ts rename to legacy/cloud/src/org/api.ts diff --git a/apps/cloud/src/org/handlers.test.ts b/legacy/cloud/src/org/handlers.test.ts similarity index 100% rename from apps/cloud/src/org/handlers.test.ts rename to legacy/cloud/src/org/handlers.test.ts diff --git a/apps/cloud/src/org/handlers.ts b/legacy/cloud/src/org/handlers.ts similarity index 100% rename from apps/cloud/src/org/handlers.ts rename to legacy/cloud/src/org/handlers.ts diff --git a/apps/cloud/src/plugins.ts b/legacy/cloud/src/plugins.ts similarity index 100% rename from apps/cloud/src/plugins.ts rename to legacy/cloud/src/plugins.ts diff --git a/apps/cloud/src/routeTree.gen.ts b/legacy/cloud/src/routeTree.gen.ts similarity index 100% rename from apps/cloud/src/routeTree.gen.ts rename to legacy/cloud/src/routeTree.gen.ts diff --git a/apps/cloud/src/router.tsx b/legacy/cloud/src/router.tsx similarity index 100% rename from apps/cloud/src/router.tsx rename to legacy/cloud/src/router.tsx diff --git a/apps/cloud/src/routes/__root.tsx b/legacy/cloud/src/routes/__root.tsx similarity index 100% rename from apps/cloud/src/routes/__root.tsx rename to legacy/cloud/src/routes/__root.tsx diff --git a/apps/cloud/src/routes/app/api-keys.tsx b/legacy/cloud/src/routes/app/api-keys.tsx similarity index 100% rename from apps/cloud/src/routes/app/api-keys.tsx rename to legacy/cloud/src/routes/app/api-keys.tsx diff --git a/apps/cloud/src/routes/app/billing.tsx b/legacy/cloud/src/routes/app/billing.tsx similarity index 100% rename from apps/cloud/src/routes/app/billing.tsx rename to legacy/cloud/src/routes/app/billing.tsx diff --git a/apps/cloud/src/routes/app/billing_.plans.tsx b/legacy/cloud/src/routes/app/billing_.plans.tsx similarity index 100% rename from apps/cloud/src/routes/app/billing_.plans.tsx rename to legacy/cloud/src/routes/app/billing_.plans.tsx diff --git a/apps/cloud/src/routes/app/org.tsx b/legacy/cloud/src/routes/app/org.tsx similarity index 100% rename from apps/cloud/src/routes/app/org.tsx rename to legacy/cloud/src/routes/app/org.tsx diff --git a/apps/cloud/src/routes/app/resume.$executionId.tsx b/legacy/cloud/src/routes/app/resume.$executionId.tsx similarity index 100% rename from apps/cloud/src/routes/app/resume.$executionId.tsx rename to legacy/cloud/src/routes/app/resume.$executionId.tsx diff --git a/apps/cloud/src/routes/app/secrets.tsx b/legacy/cloud/src/routes/app/secrets.tsx similarity index 100% rename from apps/cloud/src/routes/app/secrets.tsx rename to legacy/cloud/src/routes/app/secrets.tsx diff --git a/apps/cloud/src/routes/bare/create-org.tsx b/legacy/cloud/src/routes/bare/create-org.tsx similarity index 100% rename from apps/cloud/src/routes/bare/create-org.tsx rename to legacy/cloud/src/routes/bare/create-org.tsx diff --git a/apps/cloud/src/routes/bare/login.tsx b/legacy/cloud/src/routes/bare/login.tsx similarity index 100% rename from apps/cloud/src/routes/bare/login.tsx rename to legacy/cloud/src/routes/bare/login.tsx diff --git a/apps/cloud/src/routes/bare/setup-mcp.tsx b/legacy/cloud/src/routes/bare/setup-mcp.tsx similarity index 100% rename from apps/cloud/src/routes/bare/setup-mcp.tsx rename to legacy/cloud/src/routes/bare/setup-mcp.tsx diff --git a/apps/cloud/src/server.ts b/legacy/cloud/src/server.ts similarity index 100% rename from apps/cloud/src/server.ts rename to legacy/cloud/src/server.ts diff --git a/apps/cloud/src/start.ts b/legacy/cloud/src/start.ts similarity index 100% rename from apps/cloud/src/start.ts rename to legacy/cloud/src/start.ts diff --git a/apps/cloud/src/vite-env.d.ts b/legacy/cloud/src/vite-env.d.ts similarity index 100% rename from apps/cloud/src/vite-env.d.ts rename to legacy/cloud/src/vite-env.d.ts diff --git a/apps/cloud/src/web/auth.tsx b/legacy/cloud/src/web/auth.tsx similarity index 100% rename from apps/cloud/src/web/auth.tsx rename to legacy/cloud/src/web/auth.tsx diff --git a/apps/cloud/src/web/client.tsx b/legacy/cloud/src/web/client.tsx similarity index 100% rename from apps/cloud/src/web/client.tsx rename to legacy/cloud/src/web/client.tsx diff --git a/apps/cloud/src/web/components/create-organization-form.tsx b/legacy/cloud/src/web/components/create-organization-form.tsx similarity index 100% rename from apps/cloud/src/web/components/create-organization-form.tsx rename to legacy/cloud/src/web/components/create-organization-form.tsx diff --git a/apps/cloud/src/web/components/org-menu-slot.tsx b/legacy/cloud/src/web/components/org-menu-slot.tsx similarity index 100% rename from apps/cloud/src/web/components/org-menu-slot.tsx rename to legacy/cloud/src/web/components/org-menu-slot.tsx diff --git a/apps/cloud/src/web/components/support-options.tsx b/legacy/cloud/src/web/components/support-options.tsx similarity index 100% rename from apps/cloud/src/web/components/support-options.tsx rename to legacy/cloud/src/web/components/support-options.tsx diff --git a/apps/cloud/src/web/components/support-slot.tsx b/legacy/cloud/src/web/components/support-slot.tsx similarity index 100% rename from apps/cloud/src/web/components/support-slot.tsx rename to legacy/cloud/src/web/components/support-slot.tsx diff --git a/apps/cloud/src/web/mcp-resume-atoms.ts b/legacy/cloud/src/web/mcp-resume-atoms.ts similarity index 100% rename from apps/cloud/src/web/mcp-resume-atoms.ts rename to legacy/cloud/src/web/mcp-resume-atoms.ts diff --git a/apps/cloud/src/web/org-atoms.ts b/legacy/cloud/src/web/org-atoms.ts similarity index 100% rename from apps/cloud/src/web/org-atoms.ts rename to legacy/cloud/src/web/org-atoms.ts diff --git a/apps/cloud/src/web/pages/create-org.tsx b/legacy/cloud/src/web/pages/create-org.tsx similarity index 100% rename from apps/cloud/src/web/pages/create-org.tsx rename to legacy/cloud/src/web/pages/create-org.tsx diff --git a/apps/cloud/src/web/pages/login.tsx b/legacy/cloud/src/web/pages/login.tsx similarity index 100% rename from apps/cloud/src/web/pages/login.tsx rename to legacy/cloud/src/web/pages/login.tsx diff --git a/apps/cloud/src/web/pages/setup-mcp.tsx b/legacy/cloud/src/web/pages/setup-mcp.tsx similarity index 100% rename from apps/cloud/src/web/pages/setup-mcp.tsx rename to legacy/cloud/src/web/pages/setup-mcp.tsx diff --git a/apps/cloud/src/web/shell.tsx b/legacy/cloud/src/web/shell.tsx similarity index 100% rename from apps/cloud/src/web/shell.tsx rename to legacy/cloud/src/web/shell.tsx diff --git a/apps/cloud/test-stubs/cloudflare-workers.ts b/legacy/cloud/test-stubs/cloudflare-workers.ts similarity index 100% rename from apps/cloud/test-stubs/cloudflare-workers.ts rename to legacy/cloud/test-stubs/cloudflare-workers.ts diff --git a/apps/cloud/test-stubs/tanstack-start-entry.ts b/legacy/cloud/test-stubs/tanstack-start-entry.ts similarity index 100% rename from apps/cloud/test-stubs/tanstack-start-entry.ts rename to legacy/cloud/test-stubs/tanstack-start-entry.ts diff --git a/apps/cloud/tsconfig.json b/legacy/cloud/tsconfig.json similarity index 100% rename from apps/cloud/tsconfig.json rename to legacy/cloud/tsconfig.json diff --git a/apps/cloud/tsr.routes.ts b/legacy/cloud/tsr.routes.ts similarity index 100% rename from apps/cloud/tsr.routes.ts rename to legacy/cloud/tsr.routes.ts diff --git a/apps/cloud/vite.config.ts b/legacy/cloud/vite.config.ts similarity index 100% rename from apps/cloud/vite.config.ts rename to legacy/cloud/vite.config.ts diff --git a/apps/cloud/vitest.config.ts b/legacy/cloud/vitest.config.ts similarity index 100% rename from apps/cloud/vitest.config.ts rename to legacy/cloud/vitest.config.ts diff --git a/apps/cloud/worker-configuration.d.ts b/legacy/cloud/worker-configuration.d.ts similarity index 100% rename from apps/cloud/worker-configuration.d.ts rename to legacy/cloud/worker-configuration.d.ts diff --git a/apps/cloud/wrangler.jsonc b/legacy/cloud/wrangler.jsonc similarity index 100% rename from apps/cloud/wrangler.jsonc rename to legacy/cloud/wrangler.jsonc diff --git a/apps/desktop/.gitignore b/legacy/desktop/.gitignore similarity index 100% rename from apps/desktop/.gitignore rename to legacy/desktop/.gitignore diff --git a/apps/desktop/CHANGELOG.md b/legacy/desktop/CHANGELOG.md similarity index 100% rename from apps/desktop/CHANGELOG.md rename to legacy/desktop/CHANGELOG.md diff --git a/apps/desktop/build/entitlements.mac.plist b/legacy/desktop/build/entitlements.mac.plist similarity index 100% rename from apps/desktop/build/entitlements.mac.plist rename to legacy/desktop/build/entitlements.mac.plist diff --git a/apps/desktop/build/icon.png b/legacy/desktop/build/icon.png similarity index 100% rename from apps/desktop/build/icon.png rename to legacy/desktop/build/icon.png diff --git a/apps/desktop/electron-builder.config.ts b/legacy/desktop/electron-builder.config.ts similarity index 100% rename from apps/desktop/electron-builder.config.ts rename to legacy/desktop/electron-builder.config.ts diff --git a/apps/desktop/electron-builder.e2e.config.ts b/legacy/desktop/electron-builder.e2e.config.ts similarity index 100% rename from apps/desktop/electron-builder.e2e.config.ts rename to legacy/desktop/electron-builder.e2e.config.ts diff --git a/apps/desktop/electron.vite.config.ts b/legacy/desktop/electron.vite.config.ts similarity index 96% rename from apps/desktop/electron.vite.config.ts rename to legacy/desktop/electron.vite.config.ts index 9367117cc..df37e3a6f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/legacy/desktop/electron.vite.config.ts @@ -3,7 +3,7 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite"; import appPlugin from "@executor-js/app/vite"; const APP_ROOT = resolve(import.meta.dirname, "../../packages/app"); -const APPS_LOCAL = resolve(import.meta.dirname, "../local"); +const APPS_LOCAL = resolve(import.meta.dirname, "../../apps/local"); // Electron's runtime is provided by the launcher binary, not the bundle. // electron-log etc. ship native modules that also must stay external. diff --git a/apps/desktop/package.json b/legacy/desktop/package.json similarity index 100% rename from apps/desktop/package.json rename to legacy/desktop/package.json diff --git a/apps/desktop/scripts/build-sidecar.ts b/legacy/desktop/scripts/build-sidecar.ts similarity index 100% rename from apps/desktop/scripts/build-sidecar.ts rename to legacy/desktop/scripts/build-sidecar.ts diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/legacy/desktop/scripts/smoke-sidecar.ts similarity index 99% rename from apps/desktop/scripts/smoke-sidecar.ts rename to legacy/desktop/scripts/smoke-sidecar.ts index 88a6ee9b5..aa611a86b 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/legacy/desktop/scripts/smoke-sidecar.ts @@ -26,7 +26,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const ROOT = resolve(import.meta.dir, ".."); -const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1"); +const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../../apps/local/drizzle-legacy-v1"); const BINARY = resolve( ROOT, "resources/executor", @@ -322,7 +322,7 @@ const completePausedResult = async ( const main = async () => { if (!(await Bun.file(BINARY).exists())) { fail( - `binary not found at ${BINARY}. Run \`bun ./scripts/build-sidecar.ts\` from apps/desktop first.`, + `binary not found at ${BINARY}. Run \`bun ./scripts/build-sidecar.ts\` from legacy/desktop first.`, ); } diff --git a/apps/desktop/src/main/crash-screen.ts b/legacy/desktop/src/main/crash-screen.ts similarity index 100% rename from apps/desktop/src/main/crash-screen.ts rename to legacy/desktop/src/main/crash-screen.ts diff --git a/apps/desktop/src/main/diagnostics.ts b/legacy/desktop/src/main/diagnostics.ts similarity index 100% rename from apps/desktop/src/main/diagnostics.ts rename to legacy/desktop/src/main/diagnostics.ts diff --git a/apps/desktop/src/main/global.d.ts b/legacy/desktop/src/main/global.d.ts similarity index 100% rename from apps/desktop/src/main/global.d.ts rename to legacy/desktop/src/main/global.d.ts diff --git a/apps/desktop/src/main/index.ts b/legacy/desktop/src/main/index.ts similarity index 99% rename from apps/desktop/src/main/index.ts rename to legacy/desktop/src/main/index.ts index 798258392..c71980f01 100644 --- a/apps/desktop/src/main/index.ts +++ b/legacy/desktop/src/main/index.ts @@ -348,7 +348,7 @@ const installBearerAuthHeader = (origin: string, token: string | null) => { * it programmatically (dock on mac, BrowserWindow on linux/win). * * In dev `app.getAppPath()` returns the directory of the app's - * `package.json` (i.e. `apps/desktop`), which is more robust than + * `package.json` (i.e. `legacy/desktop`), which is more robust than * relative path math from the compiled main bundle. */ const resolveSourceIconPath = (): string => diff --git a/apps/desktop/src/main/local-auth.ts b/legacy/desktop/src/main/local-auth.ts similarity index 100% rename from apps/desktop/src/main/local-auth.ts rename to legacy/desktop/src/main/local-auth.ts diff --git a/apps/desktop/src/main/reset-state.ts b/legacy/desktop/src/main/reset-state.ts similarity index 100% rename from apps/desktop/src/main/reset-state.ts rename to legacy/desktop/src/main/reset-state.ts diff --git a/apps/desktop/src/main/service.ts b/legacy/desktop/src/main/service.ts similarity index 100% rename from apps/desktop/src/main/service.ts rename to legacy/desktop/src/main/service.ts diff --git a/apps/desktop/src/main/settings.ts b/legacy/desktop/src/main/settings.ts similarity index 100% rename from apps/desktop/src/main/settings.ts rename to legacy/desktop/src/main/settings.ts diff --git a/apps/desktop/src/main/sidecar.ts b/legacy/desktop/src/main/sidecar.ts similarity index 99% rename from apps/desktop/src/main/sidecar.ts rename to legacy/desktop/src/main/sidecar.ts index 905e987b5..4a92ceea3 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/legacy/desktop/src/main/sidecar.ts @@ -1,7 +1,7 @@ /** * Sidecar lifecycle manager run inside the Electron main process. * - * In dev: spawns `bun run apps/desktop/src/sidecar/server.ts`. + * In dev: spawns `bun run legacy/desktop/src/sidecar/server.ts`. * In prod: spawns the bundled CLI binary in foreground daemon mode. * * Either way, the child receives EXECUTOR_PORT/EXECUTOR_HOST/EXECUTOR_AUTH_TOKEN @@ -246,7 +246,7 @@ const resolveSidecarCommand = (input: { } // Dev: run the TS source directly via bun on PATH. const repoRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); - const sidecarSource = resolve(repoRoot, "apps/desktop/src/sidecar/server.ts"); + const sidecarSource = resolve(repoRoot, "legacy/desktop/src/sidecar/server.ts"); return { command: "bun", args: ["run", sidecarSource], cwd: repoRoot, cliManagedManifest: false }; }; diff --git a/apps/desktop/src/main/supervised-connection.test.ts b/legacy/desktop/src/main/supervised-connection.test.ts similarity index 100% rename from apps/desktop/src/main/supervised-connection.test.ts rename to legacy/desktop/src/main/supervised-connection.test.ts diff --git a/apps/desktop/src/main/supervised-connection.ts b/legacy/desktop/src/main/supervised-connection.ts similarity index 100% rename from apps/desktop/src/main/supervised-connection.ts rename to legacy/desktop/src/main/supervised-connection.ts diff --git a/apps/desktop/src/main/supervised-daemon.test.ts b/legacy/desktop/src/main/supervised-daemon.test.ts similarity index 100% rename from apps/desktop/src/main/supervised-daemon.test.ts rename to legacy/desktop/src/main/supervised-daemon.test.ts diff --git a/apps/desktop/src/main/supervised-daemon.ts b/legacy/desktop/src/main/supervised-daemon.ts similarity index 100% rename from apps/desktop/src/main/supervised-daemon.ts rename to legacy/desktop/src/main/supervised-daemon.ts diff --git a/apps/desktop/src/preload/index.ts b/legacy/desktop/src/preload/index.ts similarity index 100% rename from apps/desktop/src/preload/index.ts rename to legacy/desktop/src/preload/index.ts diff --git a/apps/desktop/src/renderer/index.html b/legacy/desktop/src/renderer/index.html similarity index 100% rename from apps/desktop/src/renderer/index.html rename to legacy/desktop/src/renderer/index.html diff --git a/apps/desktop/src/shared/server-settings.ts b/legacy/desktop/src/shared/server-settings.ts similarity index 100% rename from apps/desktop/src/shared/server-settings.ts rename to legacy/desktop/src/shared/server-settings.ts diff --git a/apps/desktop/src/sidecar/native-bindings.ts b/legacy/desktop/src/sidecar/native-bindings.ts similarity index 100% rename from apps/desktop/src/sidecar/native-bindings.ts rename to legacy/desktop/src/sidecar/native-bindings.ts diff --git a/apps/desktop/src/sidecar/server.ts b/legacy/desktop/src/sidecar/server.ts similarity index 100% rename from apps/desktop/src/sidecar/server.ts rename to legacy/desktop/src/sidecar/server.ts diff --git a/apps/desktop/tsconfig.json b/legacy/desktop/tsconfig.json similarity index 100% rename from apps/desktop/tsconfig.json rename to legacy/desktop/tsconfig.json diff --git a/legacy/desktop/vitest.config.ts b/legacy/desktop/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/legacy/desktop/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/package.json b/package.json index b86f52d88..d9380c4ee 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "packages/react", "packages/app", "apps/*", + "legacy/*", "examples/*", "e2e", "web" @@ -34,14 +35,19 @@ "bootstrap": "bun run scripts/bootstrap.ts", "reap": "bun run scripts/reap-dev-servers.ts", "dev": "turbo run dev --filter='!@executor-js/desktop' --filter='!@executor-js/cloud'", - "dev:desktop": "turbo run dev", "dev:cli": "EXECUTOR_DEV=1 EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-apps/local/.executor-dev} bun run apps/cli/src/main.ts", - "test": "turbo run test --filter=!@executor-js/e2e", - "test:e2e": "bun run --cwd e2e test", + "test": "turbo run test --filter='!@executor-js/e2e' --filter='!@executor-js/cloud' --filter='!@executor-js/desktop'", + "test:e2e": "bun run scripts/run-local-e2e.ts", "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts", "build:packages": "bun run --filter='@executor-js/fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build", - "typecheck": "turbo run typecheck", - "typecheck:slow": "turbo run typecheck:slow", + "typecheck": "turbo run typecheck --filter='!@executor-js/cloud' --filter='!@executor-js/desktop'", + "typecheck:slow": "turbo run typecheck:slow --filter='!@executor-js/cloud' --filter='!@executor-js/desktop'", + "legacy:dev": "turbo run dev --filter='@executor-js/cloud' --filter='@executor-js/desktop'", + "legacy:test": "turbo run test --filter='@executor-js/cloud' --filter='@executor-js/desktop'", + "legacy:test:e2e:cloud": "bun run --cwd e2e test:legacy:cloud", + "legacy:test:e2e:desktop": "bun run --cwd e2e test:legacy:desktop", + "legacy:typecheck": "turbo run typecheck --filter='@executor-js/cloud' --filter='@executor-js/desktop'", + "legacy:typecheck:slow": "turbo run typecheck:slow --filter='@executor-js/cloud' --filter='@executor-js/desktop'", "ci": "bun run lint && bun run typecheck && bun run test", "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:changelog-stubs", "lint:changelog-stubs": "bun run scripts/check-changelog-stubs.ts", @@ -76,13 +82,13 @@ "@effect/tsgo": "^0.5.2", "@effect/vitest": "catalog:", "@typescript/native-preview": "^7.0.0-dev.20260410.1", - "@vitest/expect": "catalog:", - "@vitest/mocker": "catalog:", - "@vitest/pretty-format": "catalog:", - "@vitest/runner": "catalog:", - "@vitest/snapshot": "catalog:", - "@vitest/spy": "catalog:", - "@vitest/utils": "catalog:", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "atmn": "^1.1.8", "effect": "catalog:", "knip": "^6.3.0", @@ -90,7 +96,7 @@ "oxlint": "^1.56.0", "turbo": "^2.5.6", "typescript": "^5.9.3", - "vitest": "catalog:" + "vitest": "4.1.9" }, "packageManager": "bun@1.3.11", "catalog": { diff --git a/packages/app/vite.ts b/packages/app/vite.ts index 51e27e890..3f5a563f6 100644 --- a/packages/app/vite.ts +++ b/packages/app/vite.ts @@ -28,7 +28,7 @@ interface AppPluginOptions { /** * Vite plugin bundle for the executor React app. * - * Layered into apps/local's vite config (web build) and apps/desktop's + * Layered into apps/local's vite config (web build) and legacy/desktop's * electron.vite renderer config. Consumers must pass `executorConfigPath` * so plugin client bundles get included — see option docs above. * diff --git a/packages/core/api/src/observability.ts b/packages/core/api/src/observability.ts index 1bda74d5a..74999cc55 100644 --- a/packages/core/api/src/observability.ts +++ b/packages/core/api/src/observability.ts @@ -25,7 +25,7 @@ // once; catches any cause that slipped past the typed channel and // produces the same `InternalError({ traceId })` shape. // -// Distinct from `apps/cloud/src/services/telemetry.ts` — that's the +// Distinct from `legacy/cloud/src/services/telemetry.ts`, that's the // OTEL bridge wiring spans to Axiom; this is exception capture in the // Sentry sense. // --------------------------------------------------------------------------- diff --git a/packages/core/api/src/server/request-scoped.ts b/packages/core/api/src/server/request-scoped.ts index 1436d851b..f01ac1cdb 100644 --- a/packages/core/api/src/server/request-scoped.ts +++ b/packages/core/api/src/server/request-scoped.ts @@ -25,7 +25,7 @@ // shared across two request handlers, which the runtime forbids). A // per-request MemoMap scopes memoization to a single request fiber. // -// See `apps/cloud/src/api.request-scope.node.test.ts` for the regression +// See `legacy/cloud/src/api.request-scope.node.test.ts` for the regression // coverage that pins this rule down (sequential AND concurrent cases). // --------------------------------------------------------------------------- diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index cf57304a5..5995825f8 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -135,7 +135,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => { /** * Promise-wrap an Effect-native `ExecutionEngine` (from `./engine`). * Exposed separately so callers that already hold an Effect engine - * (apps/cloud's execution-stack composes both) can convert it for hosts + * (legacy/cloud's execution-stack composes both) can convert it for hosts * that need the Promise surface (host-mcp). */ export const toPromiseExecutionEngine = ( diff --git a/packages/hosts/mcp/src/browser-approval-store.ts b/packages/hosts/mcp/src/browser-approval-store.ts index 812b7c49e..825acac0f 100644 --- a/packages/hosts/mcp/src/browser-approval-store.ts +++ b/packages/hosts/mcp/src/browser-approval-store.ts @@ -1,6 +1,6 @@ // --------------------------------------------------------------------------- // In-process browser-approval store — the single-process equivalent of the -// Durable Object's persisted approval responses (apps/cloud, host-cloudflare). +// Durable Object's persisted approval responses (legacy/cloud, host-cloudflare). // // It is the bridge between the two halves of a browser approval: // - the MCP `resume` tool long-polls `store.waitForResponse(executionId)`, diff --git a/packages/plugins/desktop-settings/src/client.tsx b/packages/plugins/desktop-settings/src/client.tsx index 3241cc4ac..c807629ab 100644 --- a/packages/plugins/desktop-settings/src/client.tsx +++ b/packages/plugins/desktop-settings/src/client.tsx @@ -5,7 +5,7 @@ * A single page mounted at `/plugins/desktop-settings/` that lets the user * inspect and configure the Electron sidecar's server connection. Talks to * the main process via `window.executor.*` (exposed by - * `apps/desktop/src/preload`). + * `legacy/desktop/src/preload`). * * The plugin is bundled into apps/local's renderer too (because executor * web + desktop share the same client bundle pipeline), but the page diff --git a/packages/plugins/openapi/src/sdk/output-schema-migration.ts b/packages/plugins/openapi/src/sdk/output-schema-migration.ts index c5a5421cb..864479609 100644 --- a/packages/plugins/openapi/src/sdk/output-schema-migration.ts +++ b/packages/plugins/openapi/src/sdk/output-schema-migration.ts @@ -5,7 +5,7 @@ // `http` side channel), so persisted schemas must describe the payload // only — otherwise describe previews show an envelope invocations no // longer return. Mirrors the cloud drizzle migration -// (apps/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql) for the +// (legacy/cloud/drizzle/0002_unwrap_openapi_output_envelope.sql) for the // libSQL-backed apps, where it runs once through the data-migration ledger. // // Idempotent: payload-shaped rows don't match the envelope signature, so diff --git a/packages/plugins/openapi/src/sdk/real-specs.test.ts b/packages/plugins/openapi/src/sdk/real-specs.test.ts index 6f59c4e33..b7bd0dc12 100644 --- a/packages/plugins/openapi/src/sdk/real-specs.test.ts +++ b/packages/plugins/openapi/src/sdk/real-specs.test.ts @@ -1,6 +1,6 @@ // Parse / extract / preview coverage against a big real-world spec. // DB-touching behaviour (addSpec, removeSpec, tool registration) moved -// to apps/cloud/src/services/sources-api.node.test.ts — those run +// to legacy/cloud/src/services/sources-api.node.test.ts, those run // through the real Drizzle/FumaDB path so storage regressions // (e.g. a per-row createMany fallback) surface automatically instead // of needing a dedicated budget assertion. diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css index 03903669c..3bdbf5371 100644 --- a/packages/react/src/styles/globals.css +++ b/packages/react/src/styles/globals.css @@ -4,7 +4,6 @@ @source "../**/*.{ts,tsx}"; @source "../../../../apps/local/src/**/*.{ts,tsx}"; -@source "../../../../apps/cloud/src/**/*.{ts,tsx}"; @source "../../../plugins/*/src/react/**/*.{ts,tsx}"; @theme inline { diff --git a/packaging/launchd/bounded-log.sh b/packaging/launchd/bounded-log.sh new file mode 100755 index 000000000..74f5508ff --- /dev/null +++ b/packaging/launchd/bounded-log.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail + +readonly MAX_BYTES=$((1024 * 1024)) +readonly GENERATIONS=3 +readonly LOG_PATH=${1:?a log path is required} + +umask 0077 +if [[ -L "$LOG_PATH" ]]; then + printf 'refusing symbolic-link log path: %s\n' "$LOG_PATH" >&2 + exit 1 +fi +touch "$LOG_PATH" +chmod 0600 "$LOG_PATH" +bytes=$(wc -c < "$LOG_PATH") +LC_ALL=C + +if [[ "$bytes" -gt "$MAX_BYTES" ]]; then + trimmed_log=$(mktemp "${LOG_PATH}.trim.XXXXXXXX") + trap 'rm -f "$trimmed_log"' EXIT + tail -c "$MAX_BYTES" "$LOG_PATH" > "$trimmed_log" + chmod 0600 "$trimmed_log" + mv -f "$trimmed_log" "$LOG_PATH" + trap - EXIT + bytes=$MAX_BYTES +fi + +rotate() { + local generation + rm -f "${LOG_PATH}.${GENERATIONS}" + generation=$((GENERATIONS - 1)) + while [[ "$generation" -ge 1 ]]; do + if [[ -f "${LOG_PATH}.${generation}" ]]; then + mv -f "${LOG_PATH}.${generation}" "${LOG_PATH}.$((generation + 1))" + fi + generation=$((generation - 1)) + done + if [[ -f "$LOG_PATH" ]]; then + mv -f "$LOG_PATH" "${LOG_PATH}.1" + fi + : > "$LOG_PATH" + chmod 0600 "$LOG_PATH" + bytes=0 +} + +line="" +while IFS= read -r line || [[ -n "$line" ]]; do + line_bytes=${#line} + if [[ "$line_bytes" -ge "$MAX_BYTES" ]]; then + line=${line:0:$((MAX_BYTES - 2))} + line_bytes=${#line} + fi + required=$((line_bytes + 1)) + if [[ $((bytes + required)) -gt "$MAX_BYTES" ]]; then + rotate + fi + printf '%s\n' "$line" >> "$LOG_PATH" + bytes=$((bytes + required)) + line="" +done diff --git a/packaging/launchd/dev.executor.gateway.plist b/packaging/launchd/dev.executor.gateway.plist new file mode 100644 index 000000000..2b7623ca5 --- /dev/null +++ b/packaging/launchd/dev.executor.gateway.plist @@ -0,0 +1,27 @@ + + + + + Label + dev.executor.gateway + ProgramArguments + + @EXECUTOR_WRAPPER@ + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 5 + ExitTimeOut + 30 + Umask + 63 + ProcessType + Background + + diff --git a/packaging/systemd/executor.env.example b/packaging/systemd/executor.env.example new file mode 100644 index 000000000..1cc2525ab --- /dev/null +++ b/packaging/systemd/executor.env.example @@ -0,0 +1,10 @@ +# Executor defaults to the loopback origin derived from its bind address. +# Set the exact browser-facing HTTPS origin when using a reverse proxy. +# EXECUTOR_PUBLIC_ORIGIN=https://executor.example.com + +# Trust forwarded client addresses only from explicitly listed proxy networks. +# Every proxy in the chain must be listed. +# EXECUTOR_TRUSTED_PROXIES=127.0.0.1/32,::1/128 + +# Rust tracing filter. Do not place credentials or API tokens in this file. +RUST_LOG=executor=info diff --git a/packaging/systemd/executor.service b/packaging/systemd/executor.service new file mode 100644 index 000000000..1da50a3de --- /dev/null +++ b/packaging/systemd/executor.service @@ -0,0 +1,58 @@ +[Unit] +Description=Executor self-hosted gateway +Documentation=https://github.com/RhysSullivan/executor +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=executor +Group=executor +WorkingDirectory=/var/lib/executor +EnvironmentFile=-/etc/executor/executor.env +ExecStart=/usr/local/bin/executor server \ + --bind 127.0.0.1:4788 \ + --data-dir /var/lib/executor \ + --master-key-file /var/lib/executor/master.key \ + --mcp-stdio-templates /etc/executor/mcp-stdio-templates.json +Restart=on-failure +RestartSec=2s +KillSignal=SIGTERM +TimeoutStartSec=30s +TimeoutStopSec=90s +UMask=0077 + +# systemd creates these paths before dropping privileges. +StateDirectory=executor +StateDirectoryMode=0700 +ConfigurationDirectory=executor +ConfigurationDirectoryMode=0750 + +# The server and any approved MCP stdio children inherit this sandbox. Add +# narrowly scoped ReadOnlyPaths= or ReadWritePaths= entries in a drop-in when a +# template needs access outside the Executor state directory. +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +PrivateDevices=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/lib/executor +ProtectControlGroups=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +ProcSubset=pid +LockPersonality=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +KeyringMode=private + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/mcp-stdio-templates.example.json b/packaging/systemd/mcp-stdio-templates.example.json new file mode 100644 index 000000000..834f94b8c --- /dev/null +++ b/packaging/systemd/mcp-stdio-templates.example.json @@ -0,0 +1,14 @@ +{ + "templates": [ + { + "name": "example", + "executable": "/usr/local/libexec/executor/example-mcp-server", + "cwd": "/var/lib/executor/mcp/example", + "arguments": ["--stdio"], + "environment": { + "LOG_LEVEL": "warn" + }, + "secretEnvironment": ["API_TOKEN"] + } + ] +} diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts index 923d868d7..15e72edeb 100644 --- a/scripts/bootstrap.ts +++ b/scripts/bootstrap.ts @@ -31,4 +31,4 @@ if (!existsSync(resolve(repoRoot, "node_modules/.bin/vitest"))) { throw new Error("bootstrap: vitest missing after install — bun install likely failed"); } -console.log("\n[bootstrap] done — `cd e2e && bun run test` runs the full suite."); +console.log("\n[bootstrap] done. See RUNNING.md for current verification commands."); diff --git a/scripts/clean.ts b/scripts/clean.ts index 859eaf641..ab5451ca2 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -20,7 +20,7 @@ for (const name of rootTargets) { const nestedTargets = new Set(["node_modules", "dist", ".turbo", ".output", ".astro"]); const nestedGlobs = /\.tsbuildinfo$/; -const searchRoots = ["apps", "packages"]; +const searchRoots = ["apps", "legacy", "packages"]; const maxDepth = 5; function clean(dir: string, depth: number) { diff --git a/scripts/install-launchd.sh b/scripts/install-launchd.sh new file mode 100755 index 000000000..0fbaedf6d --- /dev/null +++ b/scripts/install-launchd.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +set -eo pipefail + +LABEL="dev.executor.gateway" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd -P)" +TEMPLATE="${REPOSITORY_ROOT}/packaging/launchd/${LABEL}.plist" +LOG_WRITER_SOURCE="${REPOSITORY_ROOT}/packaging/launchd/bounded-log.sh" +PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist" +DATA_DIR="${EXECUTOR_DATA_DIR:-$HOME/Library/Application Support/Executor}" +TEMPLATES_FILE="${EXECUTOR_MCP_STDIO_TEMPLATES_FILE:-${DATA_DIR}/mcp-stdio-templates.json}" +PUBLIC_ORIGIN="${EXECUTOR_PUBLIC_ORIGIN:-}" +EXECUTOR_BINARY="${EXECUTOR_BINARY:-}" +TRUSTED_PROXIES="${EXECUTOR_TRUSTED_PROXIES:-}" +trusted_proxies=() +start_service=true + +usage() { + cat < Executor binary (default: executor from PATH) + --data-dir Persistent data directory + --templates MCP stdio template file + --public-origin Browser-facing origin when using an HTTPS proxy + --trusted-proxy Trust one proxy network (repeatable) + --no-start Install without loading the service + -h, --help Display this help message +EOF +} + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --binary) + [[ -n "${2:-}" ]] || fail "--binary requires a path" + EXECUTOR_BINARY="$2" + shift 2 + ;; + --data-dir) + [[ -n "${2:-}" ]] || fail "--data-dir requires a path" + DATA_DIR="$2" + shift 2 + ;; + --templates) + [[ -n "${2:-}" ]] || fail "--templates requires a path" + TEMPLATES_FILE="$2" + shift 2 + ;; + --public-origin) + [[ -n "${2:-}" ]] || fail "--public-origin requires an origin" + PUBLIC_ORIGIN="$2" + shift 2 + ;; + --trusted-proxy) + [[ -n "${2:-}" ]] || fail "--trusted-proxy requires a CIDR" + trusted_proxies+=("$2") + shift 2 + ;; + --no-start) + start_service=false + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac +done + +[[ "$(uname -s)" == "Darwin" ]] || fail "launchd installation is supported only on macOS" +[[ -f "$TEMPLATE" ]] || fail "launchd template not found at $TEMPLATE" +[[ -f "$LOG_WRITER_SOURCE" ]] || fail "bounded log writer not found at $LOG_WRITER_SOURCE" + +if [[ -z "$EXECUTOR_BINARY" ]]; then + EXECUTOR_BINARY="$(command -v executor || true)" +fi +[[ -n "$EXECUTOR_BINARY" && -x "$EXECUTOR_BINARY" ]] \ + || fail "an executable Executor binary is required" + +absolute_path() { + local path=$1 directory base + directory="$(dirname "$path")" + base="$(basename "$path")" + [[ -d "$directory" ]] || fail "directory does not exist: $directory" + printf '%s/%s\n' "$(cd "$directory" && pwd -P)" "$base" +} + +EXECUTOR_BINARY="$(absolute_path "$EXECUTOR_BINARY")" +mkdir -p "$DATA_DIR" "$(dirname "$TEMPLATES_FILE")" "$(dirname "$PLIST")" +chmod 0700 "$DATA_DIR" +DATA_DIR="$(cd "$DATA_DIR" && pwd -P)" + +if [[ ! -e "$TEMPLATES_FILE" ]]; then + printf '{\n "templates": []\n}\n' > "$TEMPLATES_FILE" +fi +[[ -f "$TEMPLATES_FILE" && ! -L "$TEMPLATES_FILE" ]] \ + || fail "the MCP stdio template path must be a regular file" +chmod 0600 "$TEMPLATES_FILE" +TEMPLATES_FILE="$(absolute_path "$TEMPLATES_FILE")" + +if [[ -n "$TRUSTED_PROXIES" ]]; then + IFS=',' read -r -a environment_proxies <<< "$TRUSTED_PROXIES" + trusted_proxies+=("${environment_proxies[@]}") +fi + +for value in "$EXECUTOR_BINARY" "$DATA_DIR" "$TEMPLATES_FILE" "$PUBLIC_ORIGIN"; do + case "$value" in + *'&'*|*'<'*|*'>'*|*'|'*|*'\'*) + fail "service paths and origins cannot contain XML or template metacharacters" + ;; + esac +done + +wrapper="${DATA_DIR}/run-launchd.sh" +log_writer="${DATA_DIR}/bounded-log.sh" +private_log="${DATA_DIR}/executor.log" +temporary_log_writer="$(mktemp "${log_writer}.XXXXXXXX")" +trap 'rm -f "$temporary_log_writer"' EXIT +cp "$LOG_WRITER_SOURCE" "$temporary_log_writer" +chmod 0700 "$temporary_log_writer" +mv -f "$temporary_log_writer" "$log_writer" +trap - EXIT + +temporary_wrapper="$(mktemp "${wrapper}.XXXXXXXX")" +trap 'rm -f "$temporary_wrapper"' EXIT +{ + printf '#!/bin/bash\nset -euo pipefail\nexec ' + printf '%q ' "$EXECUTOR_BINARY" server --data-dir "$DATA_DIR" \ + --mcp-stdio-templates "$TEMPLATES_FILE" + if [[ -n "$PUBLIC_ORIGIN" ]]; then + printf '%q ' --public-origin "$PUBLIC_ORIGIN" + fi + if [[ ${#trusted_proxies[@]} -gt 0 ]]; then + for trusted_proxy in "${trusted_proxies[@]}"; do + [[ -n "$trusted_proxy" ]] || fail "trusted proxy entries cannot be empty" + printf '%q ' --trusted-proxy "$trusted_proxy" + done + fi + printf '> >(%q %q) 2>&1\n' "$log_writer" "$private_log" +} > "$temporary_wrapper" +chmod 0700 "$temporary_wrapper" +mv -f "$temporary_wrapper" "$wrapper" +trap - EXIT + +temporary_plist="$(mktemp "${PLIST}.XXXXXXXX")" +trap 'rm -f "$temporary_plist"' EXIT +sed -e "s|@EXECUTOR_WRAPPER@|${wrapper}|g" "$TEMPLATE" > "$temporary_plist" + +plutil -lint "$temporary_plist" >/dev/null +chmod 0600 "$temporary_plist" +mv -f "$temporary_plist" "$PLIST" +trap - EXIT + +domain="gui/$(id -u)" +launchctl bootout "$domain/$LABEL" >/dev/null 2>&1 || true +if [[ "$start_service" == "true" ]]; then + launchctl enable "$domain/$LABEL" + launchctl bootstrap "$domain" "$PLIST" + ready=false + previous_pid="" + consecutive_checks=0 + for _ in {1..30}; do + current_pid="$(launchctl print "$domain/$LABEL" 2>/dev/null \ + | awk '/^[[:space:]]*pid = [0-9]+$/ { print $3; exit }')" + if [[ -n "$current_pid" ]] \ + && curl --fail --silent --show-error --noproxy '*' \ + --connect-timeout 1 --max-time 1 \ + http://127.0.0.1:4788/healthz >/dev/null 2>&1; then + if [[ "$current_pid" == "$previous_pid" ]]; then + consecutive_checks=$((consecutive_checks + 1)) + else + consecutive_checks=1 + previous_pid="$current_pid" + fi + if [[ "$consecutive_checks" -ge 2 ]]; then + ready=true + break + fi + else + consecutive_checks=0 + previous_pid="" + fi + sleep 1 + done + if [[ "$ready" != "true" ]]; then + launchctl print "$domain/$LABEL" >&2 || true + launchctl bootout "$domain/$LABEL" >/dev/null 2>&1 || true + fail "Executor did not become healthy within 30 seconds" + fi + printf 'Executor is running at http://127.0.0.1:4788\n' + printf 'Read first-boot setup and logs at %s\n' "$private_log" +else + printf 'Installed %s\n' "$PLIST" + printf 'Load it with: launchctl bootstrap %s %q\n' "$domain" "$PLIST" +fi diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh new file mode 100755 index 000000000..e975dd611 --- /dev/null +++ b/scripts/install-systemd.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly APP_USER="executor" +readonly APP_GROUP="executor" +readonly BINARY_TARGET="/usr/local/bin/executor" +readonly CONFIG_DIR="/etc/executor" +readonly DATA_DIR="/var/lib/executor" +readonly UNIT_TARGET="/etc/systemd/system/executor.service" + +usage() { + cat <<'EOF' +Install Executor as a hardened systemd service. + +Usage: sudo scripts/install-systemd.sh [--binary /path/to/executor] [--no-start] + +Options: + --binary PATH Install this binary. Defaults to executor from PATH. + --no-start Install and enable the unit without starting it. + -h, --help Show this help. + +The installer never replaces an existing master key, environment file, or MCP +stdio template registry. +EOF +} + +binary_source="" +start_service=true +while (($# > 0)); do + case "$1" in + --binary) + if (($# < 2)); then + echo "--binary requires a path" >&2 + exit 2 + fi + binary_source=$2 + shift 2 + ;; + --no-start) + start_service=false + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ ${EUID} -ne 0 ]]; then + echo "run this installer as root, for example with sudo" >&2 + exit 1 +fi +if ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is required" >&2 + exit 1 +fi +if [[ ${start_service} == true ]] && ! command -v curl >/dev/null 2>&1; then + echo "curl is required for the startup readiness check" >&2 + exit 1 +fi + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +repo_dir=$(cd -- "${script_dir}/.." && pwd -P) +unit_source="${repo_dir}/packaging/systemd/executor.service" +env_source="${repo_dir}/packaging/systemd/executor.env.example" + +if [[ -z ${binary_source} ]]; then + binary_source=$(command -v executor || true) +fi +if [[ -z ${binary_source} || ! -f ${binary_source} || ! -x ${binary_source} ]]; then + echo "an executable Executor binary is required; pass --binary PATH" >&2 + exit 1 +fi +binary_source=$(cd -- "$(dirname -- "${binary_source}")" && pwd -P)/$(basename -- "${binary_source}") + +if ! getent group "${APP_GROUP}" >/dev/null; then + groupadd --system "${APP_GROUP}" +fi +if ! id -u "${APP_USER}" >/dev/null 2>&1; then + useradd \ + --system \ + --gid "${APP_GROUP}" \ + --home-dir "${DATA_DIR}" \ + --shell "$(command -v nologin || echo /usr/sbin/nologin)" \ + "${APP_USER}" +fi +IFS=: read -r _ _ app_uid _ _ app_home app_shell \ + <<< "$(getent passwd "${APP_USER}")" +app_primary_group=$(id -gn "${APP_USER}") +password_status=$(passwd -S "${APP_USER}" 2>/dev/null | awk '{print $2}') +if [[ ${app_uid} -eq 0 \ + || ${app_home} != "${DATA_DIR}" \ + || ! ${app_shell} =~ /(nologin|false)$ \ + || ${app_primary_group} != "${APP_GROUP}" \ + || ! ${password_status} =~ ^(L|LK)$ ]]; then + echo "the executor account exists but is not the expected locked system account" >&2 + echo "refusing to grant it access to Executor state" >&2 + exit 1 +fi + +install -d -o root -g "${APP_GROUP}" -m 0750 "${CONFIG_DIR}" +install -d -o "${APP_USER}" -g "${APP_GROUP}" -m 0700 "${DATA_DIR}" + +if [[ ${binary_source} != "${BINARY_TARGET}" ]]; then + install -o root -g root -m 0755 "${binary_source}" "${BINARY_TARGET}" +else + chown root:root "${BINARY_TARGET}" + chmod 0755 "${BINARY_TARGET}" +fi +install -o root -g root -m 0644 "${unit_source}" "${UNIT_TARGET}" + +for managed_file in \ + "${CONFIG_DIR}/executor.env" \ + "${CONFIG_DIR}/mcp-stdio-templates.json" \ + "${DATA_DIR}/master.key"; do + if [[ -L ${managed_file} ]]; then + echo "refusing symbolic link at managed path: ${managed_file}" >&2 + exit 1 + fi + if [[ -e ${managed_file} && ! -f ${managed_file} ]]; then + echo "managed path is not a regular file: ${managed_file}" >&2 + exit 1 + fi +done + +if [[ ! -e ${CONFIG_DIR}/executor.env ]]; then + install -o root -g "${APP_GROUP}" -m 0640 \ + "${env_source}" "${CONFIG_DIR}/executor.env" +fi +if [[ ! -e ${CONFIG_DIR}/mcp-stdio-templates.json ]]; then + umask 0027 + printf '{\n "templates": []\n}\n' > "${CONFIG_DIR}/mcp-stdio-templates.json" + chown root:"${APP_GROUP}" "${CONFIG_DIR}/mcp-stdio-templates.json" + chmod 0640 "${CONFIG_DIR}/mcp-stdio-templates.json" +fi +if [[ -e ${DATA_DIR}/executor.db && ! -e ${DATA_DIR}/master.key ]]; then + echo "an existing Executor database has no master key; restore the original key" >&2 + exit 1 +fi +if [[ ! -e ${DATA_DIR}/master.key ]]; then + key_tmp=$(mktemp "${DATA_DIR}/.master.key.XXXXXXXX") + trap 'rm -f -- "${key_tmp:-}"' EXIT + chmod 0600 "${key_tmp}" + head -c 32 /dev/urandom > "${key_tmp}" + chown "${APP_USER}:${APP_GROUP}" "${key_tmp}" + if ! ln -- "${key_tmp}" "${DATA_DIR}/master.key"; then + echo "master key path appeared during installation; refusing to replace it" >&2 + exit 1 + fi + rm -f -- "${key_tmp}" + trap - EXIT +fi + +if [[ $(wc -c < "${DATA_DIR}/master.key") -ne 32 ]]; then + echo "${DATA_DIR}/master.key must contain exactly 32 bytes" >&2 + exit 1 +fi +if [[ $(stat -c %h "${DATA_DIR}/master.key") -ne 1 ]]; then + echo "${DATA_DIR}/master.key must not have additional hard links" >&2 + exit 1 +fi +chown root:"${APP_GROUP}" \ + "${CONFIG_DIR}/executor.env" \ + "${CONFIG_DIR}/mcp-stdio-templates.json" +chmod 0640 \ + "${CONFIG_DIR}/executor.env" \ + "${CONFIG_DIR}/mcp-stdio-templates.json" +chown "${APP_USER}:${APP_GROUP}" "${DATA_DIR}/master.key" +chmod 0600 "${DATA_DIR}/master.key" + +systemctl daemon-reload +systemctl enable executor.service +if [[ ${start_service} == true ]]; then + if ! systemctl restart executor.service; then + systemctl status --no-pager executor.service || true + exit 1 + fi + ready=false + healthy_checks=0 + last_main_pid=0 + for _ in {1..30}; do + main_pid=$(systemctl show --property MainPID --value executor.service) + if systemctl is-active --quiet executor.service \ + && [[ ${main_pid} =~ ^[1-9][0-9]*$ ]] \ + && curl --noproxy '*' --fail --silent --max-time 1 \ + http://127.0.0.1:4788/healthz >/dev/null; then + if [[ ${main_pid} == "${last_main_pid}" ]]; then + ((healthy_checks += 1)) + else + healthy_checks=1 + last_main_pid=${main_pid} + fi + if ((healthy_checks >= 2)); then + ready=true + break + fi + else + healthy_checks=0 + last_main_pid=0 + fi + if systemctl is-failed --quiet executor.service; then + break + fi + sleep 1 + done + if [[ ${ready} != true ]]; then + echo "Executor did not become ready" >&2 + systemctl status --no-pager executor.service || true + journalctl --unit executor.service --lines 30 --no-pager || true + exit 1 + fi + echo "Executor is ready at http://127.0.0.1:4788" +else + echo "Executor is installed. Start it with: systemctl start executor.service" +fi +echo "Follow logs with: journalctl -u executor.service -f" diff --git a/scripts/install.sh b/scripts/install.sh index 345743148..862008f08 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -APP=executor -REPO=RhysSullivan/executor -MUTED='\033[0;2m' -RED='\033[0;31m' -ORANGE='\033[38;5;214m' -NC='\033[0m' +APP="executor" +REPOSITORY="${EXECUTOR_REPOSITORY:-RhysSullivan/executor}" +INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}" +requested_version="${VERSION:-}" +binary_path="" +local_archive_path="" +local_checksum_path="" +no_modify_path=false usage() { cat < Install a specific version (e.g. 1.4.12) + -v, --version Install a specific version, such as 2.0.0 -b, --binary Install from a local binary instead of downloading - --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) + -a, --archive Install a release archive from disk + --checksum Checksum sidecar for --archive (default: .sha256) + --no-modify-path Do not modify shell configuration files + +Environment: + EXECUTOR_INSTALL_DIR Binary directory (default: \$HOME/.executor/bin) + EXECUTOR_REPOSITORY GitHub owner/repository for downloads + VERSION Version to install when --version is omitted Examples: - curl -fsSL https://raw.githubusercontent.com/${REPO}/main/scripts/install.sh | bash - curl -fsSL https://raw.githubusercontent.com/${REPO}/main/scripts/install.sh | bash -s -- --version 1.4.12 - ./install.sh --binary /path/to/executor + curl -fsSL https://raw.githubusercontent.com/${REPOSITORY}/main/scripts/install.sh | bash + curl -fsSL https://raw.githubusercontent.com/${REPOSITORY}/main/scripts/install.sh | bash -s -- --version 2.0.0 + ./scripts/install.sh --binary ./target/release/executor EOF } -requested_version=${VERSION:-} -no_modify_path=false -binary_path="" +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} while [[ $# -gt 0 ]]; do case "$1" in @@ -38,250 +48,259 @@ while [[ $# -gt 0 ]]; do exit 0 ;; -v|--version) - if [[ -n "${2:-}" ]]; then - requested_version="$2" - shift 2 - else - echo -e "${RED}Error: --version requires a version argument${NC}" >&2 - exit 1 - fi + [[ -n "${2:-}" ]] || fail "--version requires a version" + requested_version="$2" + shift 2 ;; -b|--binary) - if [[ -n "${2:-}" ]]; then - binary_path="$2" - shift 2 - else - echo -e "${RED}Error: --binary requires a path argument${NC}" >&2 - exit 1 - fi + [[ -n "${2:-}" ]] || fail "--binary requires a path" + binary_path="$2" + shift 2 + ;; + -a|--archive) + [[ -n "${2:-}" ]] || fail "--archive requires a path" + local_archive_path="$2" + shift 2 + ;; + --checksum) + [[ -n "${2:-}" ]] || fail "--checksum requires a path" + local_checksum_path="$2" + shift 2 ;; --no-modify-path) no_modify_path=true shift ;; *) - echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2 - shift + fail "unknown option: $1" ;; esac done -INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}" -mkdir -p "$INSTALL_DIR" - -print_message() { - local level=$1 message=$2 color="" - case "$level" in - info) color="${NC}" ;; - warning) color="${ORANGE}" ;; - error) color="${RED}" ;; - esac - echo -e "${color}${message}${NC}" -} - -if [[ -n "$binary_path" ]]; then - if [[ ! -f "$binary_path" ]]; then - print_message error "Error: binary not found at $binary_path" - exit 1 - fi - specific_version="local" -else - raw_os=$(uname -s) - case "$raw_os" in - Darwin*) os="darwin" ;; - Linux*) os="linux" ;; - MINGW*|MSYS*|CYGWIN*) os="windows" ;; - *) - print_message error "Unsupported OS: $raw_os" - exit 1 - ;; - esac - - arch=$(uname -m) - case "$arch" in - aarch64|arm64) arch="arm64" ;; - x86_64|amd64) arch="x64" ;; - *) - print_message error "Unsupported architecture: $arch" - exit 1 - ;; - esac - - # Apple Silicon under Rosetta reports x64 — install the native arm64 build. - if [[ "$os" == "darwin" && "$arch" == "x64" ]]; then - if [[ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" == "1" ]]; then - arch="arm64" - fi - fi - - is_musl=false - if [[ "$os" == "linux" ]]; then - if [[ -f /etc/alpine-release ]]; then - is_musl=true - elif command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then - is_musl=true - fi - fi - - target="${os}-${arch}" - if [[ "$is_musl" == "true" ]]; then - target="${target}-musl" - fi - - archive_ext=".zip" - if [[ "$os" == "linux" ]]; then - archive_ext=".tar.gz" - fi +if [[ -n "$binary_path" && -n "$local_archive_path" ]]; then + fail "--binary and --archive cannot be used together" +fi +if [[ -n "$local_checksum_path" && -z "$local_archive_path" ]]; then + fail "--checksum requires --archive" +fi - filename="${APP}-${target}${archive_ext}" +case "$REPOSITORY" in + ''|/*|*/|*/*/*|*[!A-Za-z0-9._/-]*) + fail "EXECUTOR_REPOSITORY must be an owner/repository name" + ;; +esac +[[ "$INSTALL_DIR" == /* ]] || fail "EXECUTOR_INSTALL_DIR must be an absolute path" +case "$INSTALL_DIR" in + *:*|*$'\n'*|*$'\r'*) fail "EXECUTOR_INSTALL_DIR cannot contain colons or line breaks" ;; +esac - if [[ "$os" == "linux" ]]; then - if ! command -v tar >/dev/null 2>&1; then - print_message error "Error: 'tar' is required but not installed." - exit 1 - fi - else - if ! command -v unzip >/dev/null 2>&1; then - print_message error "Error: 'unzip' is required but not installed." - exit 1 - fi - fi +case "${OSTYPE:-}" in + darwin*) platform="apple-darwin" ;; + linux*) platform="unknown-linux-gnu" ;; + *) fail "Executor release binaries support only Linux and macOS" ;; +esac - if [[ -z "$requested_version" ]]; then - url="https://github.com/${REPO}/releases/latest/download/${filename}" - specific_version=$( - curl -s "https://api.github.com/repos/${REPO}/releases/latest" \ - | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' - ) - if [[ -z "$specific_version" ]]; then - print_message error "Failed to fetch latest version metadata" - exit 1 - fi - else - requested_version="${requested_version#v}" - url="https://github.com/${REPO}/releases/download/v${requested_version}/${filename}" - specific_version="$requested_version" +machine="$(uname -m)" +case "$machine" in + x86_64|amd64) architecture="x86_64" ;; + arm64|aarch64) architecture="aarch64" ;; + *) fail "unsupported architecture: $machine" ;; +esac - http_status=$(curl -sI -o /dev/null -w "%{http_code}" \ - "https://github.com/${REPO}/releases/tag/v${requested_version}") - if [[ "$http_status" == "404" ]]; then - print_message error "Error: release v${requested_version} not found" - print_message info "${MUTED}Available releases: https://github.com/${REPO}/releases${NC}" - exit 1 - fi +if [[ "$platform" == "apple-darwin" && "$architecture" == "x86_64" ]]; then + if [[ "$(sysctl -n sysctl.proc_translated 2>/dev/null || printf '0')" == "1" ]]; then + architecture="aarch64" fi fi -check_existing_version() { - if command -v executor >/dev/null 2>&1; then - local installed - installed=$(executor --version 2>/dev/null || echo "") - if [[ "$installed" == "$specific_version" ]]; then - print_message info "${MUTED}Version ${NC}${specific_version}${MUTED} already installed${NC}" - exit 0 - fi - print_message info "${MUTED}Replacing installed version ${NC}${installed}" - fi -} +target="${architecture}-${platform}" +archive="${APP}-${target}.tar.gz" -download_and_install() { - print_message info "\n${MUTED}Installing ${NC}${APP} ${MUTED}version: ${NC}${specific_version}" - local tmp_dir - tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/${APP}_install_XXXXXXXXXX") - trap 'rm -rf "$tmp_dir"' RETURN +make_temp_dir() { + mktemp -d "${TMPDIR:-/tmp}/${APP}-install.XXXXXXXX" +} - curl -# -L -o "${tmp_dir}/${filename}" "$url" +verify_checksum() { + local checksum_file=$1 archive_path=$2 expected actual + expected="$(awk 'NF { print $1; exit }' "$checksum_file")" + [[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || fail "release checksum is malformed" - if [[ "$os" == "linux" ]]; then - tar -xzf "${tmp_dir}/${filename}" -C "$tmp_dir" + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive_path" | awk '{ print $1 }')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" else - unzip -q "${tmp_dir}/${filename}" -d "$tmp_dir" + fail "sha256sum or shasum is required to verify the release" fi - # The archive is flat — the binary plus sidecars (emscripten-module.wasm, - # keyring.node) sit at the root. Copy them all into INSTALL_DIR so the - # binary's relative-path lookups still resolve. - rm -f "${tmp_dir}/${filename}" - cp -R "${tmp_dir}/." "${INSTALL_DIR}/" + [[ "$actual" == "$expected" ]] || fail "release checksum verification failed" +} - chmod 755 "${INSTALL_DIR}/${APP}" - rm -rf "$tmp_dir" +install_binary() { + local source=$1 temporary + mkdir -p "$INSTALL_DIR" + temporary="$(mktemp "${INSTALL_DIR}/.${APP}.XXXXXXXX")" + trap 'rm -f "$temporary"' RETURN + cp "$source" "$temporary" + chmod 0755 "$temporary" + mv -f "$temporary" "${INSTALL_DIR}/${APP}" trap - RETURN } -install_from_binary() { - print_message info "\n${MUTED}Installing ${NC}${APP} ${MUTED}from: ${NC}${binary_path}" - cp "$binary_path" "${INSTALL_DIR}/${APP}" - chmod 755 "${INSTALL_DIR}/${APP}" +install_support_file() { + local source=$1 destination=$2 temporary + temporary="$(mktemp "${INSTALL_DIR}/.${destination}.XXXXXXXX")" + trap 'rm -f "$temporary"' RETURN + cp "$source" "$temporary" + chmod 0644 "$temporary" + mv -f "$temporary" "${INSTALL_DIR}/${destination}" + trap - RETURN } if [[ -n "$binary_path" ]]; then - install_from_binary + [[ -f "$binary_path" ]] || fail "binary not found at $binary_path" + install_binary "$binary_path" else - check_existing_version - download_and_install + command -v tar >/dev/null 2>&1 || fail "tar is required" + + temporary_directory="$(make_temp_dir)" + trap 'rm -rf "$temporary_directory"' EXIT + if [[ -n "$local_archive_path" ]]; then + [[ -f "$local_archive_path" ]] || fail "archive not found at $local_archive_path" + archive_path="$local_archive_path" + checksum_path="${local_checksum_path:-${local_archive_path}.sha256}" + [[ -f "$checksum_path" ]] || fail "checksum not found at $checksum_path" + else + command -v curl >/dev/null 2>&1 || fail "curl is required" + requested_version="${requested_version#v}" + if [[ -n "$requested_version" ]]; then + case "$requested_version" in + *[!A-Za-z0-9._-]*) fail "the requested version contains unsafe characters" ;; + esac + release_base="https://github.com/${REPOSITORY}/releases/download/v${requested_version}" + version_label="v${requested_version}" + else + release_base="https://github.com/${REPOSITORY}/releases/latest/download" + version_label="latest" + fi + archive_path="${temporary_directory}/${archive}" + checksum_path="${archive_path}.sha256" + + printf 'Downloading Executor %s for %s\n' "$version_label" "$target" + curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --max-filesize 268435456 \ + --output "$archive_path" "${release_base}/${archive}" + curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --max-filesize 1048576 \ + --output "$checksum_path" "${release_base}/${archive}.sha256" + fi + [[ "$(wc -c < "$archive_path")" -le 268435456 ]] \ + || fail "release archive exceeds 256 MiB" + [[ "$(wc -c < "$checksum_path")" -le 1048576 ]] \ + || fail "release checksum exceeds 1 MiB" + verify_checksum "$checksum_path" "$archive_path" + + members_path="${temporary_directory}/archive-members.txt" + ( + ulimit -f 2048 + ulimit -t 30 + tar -tzf "$archive_path" > "$members_path" + ) + archive_members="$(< "$members_path")" + expected_members=$'executor\nLICENSE\nTHIRD_PARTY_LICENSES.html\nTHIRD_PARTY_JAVASCRIPT_LICENSES.json' + [[ "$archive_members" == "$expected_members" ]] \ + || fail "release archive has unexpected members" + verbose_members_path="${temporary_directory}/archive-members-verbose.txt" + ( + ulimit -f 2048 + ulimit -t 30 + tar -tvzf "$archive_path" > "$verbose_members_path" + ) + while IFS= read -r member; do + [[ "${member:0:1}" == "-" ]] \ + || fail "release archive members must be regular files" + done < "$verbose_members_path" + + extracted_directory="${temporary_directory}/extracted" + mkdir "$extracted_directory" + extract_member() { + local member=$1 file_blocks=$2 + ( + ulimit -f "$file_blocks" + ulimit -t 60 + tar -xOzf "$archive_path" "$member" \ + > "${extracted_directory}/${member}" + ) + } + extract_member executor 524288 + extract_member LICENSE 4096 + extract_member THIRD_PARTY_LICENSES.html 131072 + extract_member THIRD_PARTY_JAVASCRIPT_LICENSES.json 131072 + + extracted_binary="${extracted_directory}/${APP}" + [[ -f "$extracted_binary" && -s "$extracted_binary" && ! -L "$extracted_binary" ]] \ + || fail "release archive does not contain a regular executor binary" + for support_file in \ + LICENSE \ + THIRD_PARTY_LICENSES.html \ + THIRD_PARTY_JAVASCRIPT_LICENSES.json; do + [[ -s "${extracted_directory}/${support_file}" ]] \ + || fail "release archive contains an empty support file" + done + install_binary "$extracted_binary" + install_support_file "${extracted_directory}/LICENSE" LICENSE + install_support_file \ + "${extracted_directory}/THIRD_PARTY_LICENSES.html" \ + THIRD_PARTY_LICENSES.html + install_support_file \ + "${extracted_directory}/THIRD_PARTY_JAVASCRIPT_LICENSES.json" \ + THIRD_PARTY_JAVASCRIPT_LICENSES.json fi add_to_path() { local config_file=$1 command=$2 - if grep -Fxq "$command" "$config_file"; then - print_message info "${MUTED}Already in ${NC}${config_file}" - elif [[ -w "$config_file" ]]; then - echo -e "\n# executor" >> "$config_file" - echo "$command" >> "$config_file" - print_message info "${MUTED}Added ${NC}${APP} ${MUTED}to \$PATH in ${NC}${config_file}" + if grep -Fqx "$command" "$config_file"; then + return + fi + if [[ -w "$config_file" ]]; then + printf '\n# Executor\n%s\n' "$command" >> "$config_file" + printf 'Added Executor to PATH in %s\n' "$config_file" else - print_message warning "Manually add to ${config_file}:" - print_message info " $command" + printf 'Add this to %s:\n %s\n' "$config_file" "$command" fi } -XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" -current_shell=$(basename "${SHELL:-bash}") - -case "$current_shell" in - fish) - config_files="$HOME/.config/fish/config.fish" - ;; - zsh) - config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv" - ;; - bash) - config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" - ;; - *) - config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" - ;; -esac - -if [[ "$no_modify_path" != "true" ]]; then - config_file="" - for file in $config_files; do - if [[ -f "$file" ]]; then - config_file=$file - break - fi - done +if [[ "$no_modify_path" != "true" && ":${PATH}:" != *":${INSTALL_DIR}:"* ]]; then + current_shell="$(basename "${SHELL:-bash}")" + case "$current_shell" in + fish) + config_file="$HOME/.config/fish/config.fish" + quoted_install_dir=${INSTALL_DIR//\\/\\\\} + quoted_install_dir=${quoted_install_dir//\'/\\\'} + path_command="fish_add_path -- '$quoted_install_dir'" + ;; + zsh) + config_file="${ZDOTDIR:-$HOME}/.zshrc" + printf -v quoted_install_dir '%q' "$INSTALL_DIR" + path_command="export PATH=$quoted_install_dir:\$PATH" + ;; + *) + config_file="$HOME/.bashrc" + printf -v quoted_install_dir '%q' "$INSTALL_DIR" + path_command="export PATH=$quoted_install_dir:\$PATH" + ;; + esac - if [[ -z "$config_file" ]]; then - print_message warning "No config file found for ${current_shell}. Add manually:" - print_message info " export PATH=${INSTALL_DIR}:\$PATH" - elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then - case "$current_shell" in - fish) add_to_path "$config_file" "fish_add_path $INSTALL_DIR" ;; - *) add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" ;; - esac + if [[ -f "$config_file" ]]; then + add_to_path "$config_file" "$path_command" + else + printf 'Add Executor to PATH:\n %s\n' "$path_command" fi fi -if [[ -n "${GITHUB_ACTIONS-}" && "${GITHUB_ACTIONS}" == "true" ]]; then - echo "$INSTALL_DIR" >> "$GITHUB_PATH" - print_message info "${MUTED}Added ${NC}${INSTALL_DIR}${MUTED} to \$GITHUB_PATH${NC}" +if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + printf '%s\n' "$INSTALL_DIR" >> "$GITHUB_PATH" fi -print_message info "" -print_message info "${MUTED}Installed ${NC}${APP} ${MUTED}to ${NC}${INSTALL_DIR}/${APP}" -print_message info "" -print_message info "${MUTED}Get started:${NC}" -print_message info " ${APP} web" -print_message info "" +printf '\nInstalled Executor at %s\n' "${INSTALL_DIR}/${APP}" +printf 'Start it with: executor server\n' diff --git a/scripts/oxlint-plugin-executor/rules/no-cross-package-relative-imports.js b/scripts/oxlint-plugin-executor/rules/no-cross-package-relative-imports.js index 0c34fe6be..78e250ffe 100644 --- a/scripts/oxlint-plugin-executor/rules/no-cross-package-relative-imports.js +++ b/scripts/oxlint-plugin-executor/rules/no-cross-package-relative-imports.js @@ -50,7 +50,7 @@ function findPackageRoot(absolutePath) { function collectPackageRoots() { const roots = []; - for (const root of ["packages", "apps", "examples"]) { + for (const root of ["packages", "apps", "legacy", "examples"]) { collectPackageRootsFrom(path.join(repoRoot, root), roots); } return roots; diff --git a/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js b/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js index 75ca1db1e..bcbf88555 100644 --- a/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js +++ b/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js @@ -3,9 +3,12 @@ import { getPropertyName, isIdentifier, toRepoRelative, unwrapExpression } from const message = "Do not access cloud executor tables directly outside DB schema wiring. Executor-domain table access must go through the scoped SDK adapter so scope_id filtering cannot be skipped."; -const allowedFiles = new Set(["apps/cloud/src/db/db.ts", "apps/cloud/src/db/db.schema.test.ts"]); +const allowedFiles = new Set([ + "legacy/cloud/src/db/db.ts", + "legacy/cloud/src/db/db.schema.test.ts", +]); -const isCloudSource = (filename) => toRepoRelative(filename).startsWith("apps/cloud/src/"); +const isCloudSource = (filename) => toRepoRelative(filename).startsWith("legacy/cloud/src/"); const isDirectExecutorSchemaImport = (specifier) => specifier === "./executor-schema" || diff --git a/scripts/reap-dev-servers.ts b/scripts/reap-dev-servers.ts index 5a9aa9c62..1fd312bae 100644 --- a/scripts/reap-dev-servers.ts +++ b/scripts/reap-dev-servers.ts @@ -31,7 +31,7 @@ for (const line of ps.split("\n")) { if (Number(pidText) === process.pid) continue; // The checkout root is whatever absolute path prefixes node_modules/ or a // workspace dir in the command line. - const pathMatch = command!.match(/(\/[^ ]*?)\/(?:node_modules|apps|packages|e2e)\//); + const pathMatch = command!.match(/(\/[^ ]*?)\/(?:node_modules|apps|legacy|packages|e2e)\//); const checkout = pathMatch?.[1]; const orphan = checkout !== undefined && !existsSync(checkout); candidates.push({ pid: Number(pidText), command: command!.slice(0, 160), checkout, orphan }); diff --git a/scripts/run-local-e2e.ts b/scripts/run-local-e2e.ts new file mode 100644 index 000000000..ec453202b --- /dev/null +++ b/scripts/run-local-e2e.ts @@ -0,0 +1,22 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); + +execFileSync("bun", ["run", "build"], { + cwd: fileURLToPath(new URL("../web", import.meta.url)), + stdio: "inherit", +}); +execFileSync("cargo", ["build", "--bin", "executor"], { + cwd: root, + env: { ...process.env, EXECUTOR_WEB_ASSETS_DIR: "web/build" }, + stdio: "inherit", +}); +execFileSync("bun", ["run", "test:unit"], { + cwd: fileURLToPath(new URL("../e2e", import.meta.url)), + stdio: "inherit", +}); +execFileSync("bun", ["run", "test"], { + cwd: fileURLToPath(new URL("../e2e", import.meta.url)), + stdio: "inherit", +}); diff --git a/scripts/test-install-release-archive.sh b/scripts/test-install-release-archive.sh new file mode 100755 index 000000000..bd2240303 --- /dev/null +++ b/scripts/test-install-release-archive.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +fixture="$(mktemp -d "${TMPDIR:-/tmp}/executor-release-fixture.XXXXXXXX")" +trap 'rm -rf "$fixture"' EXIT + +stage="${fixture}/stage" +mkdir "$stage" +printf '#!/bin/sh\nexit 0\n' > "${stage}/executor" +chmod 0755 "${stage}/executor" +printf 'MIT fixture\n' > "${stage}/LICENSE" +printf 'Rust license fixture\n' > "${stage}/THIRD_PARTY_LICENSES.html" +printf '[{"name":"Svelte","identifier":"MIT","text":"fixture"}]\n' \ + > "${stage}/THIRD_PARTY_JAVASCRIPT_LICENSES.json" + +archive="${fixture}/executor-fixture.tar.gz" +tar -C "$stage" -czf "$archive" \ + executor LICENSE THIRD_PARTY_LICENSES.html \ + THIRD_PARTY_JAVASCRIPT_LICENSES.json +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$archive" > "${archive}.sha256" +else + shasum -a 256 "$archive" > "${archive}.sha256" +fi + +install_dir="${fixture}/installed" +EXECUTOR_INSTALL_DIR="$install_dir" \ + "${repo_root}/scripts/install.sh" \ + --archive "$archive" \ + --no-modify-path >/dev/null + +for installed in \ + executor \ + LICENSE \ + THIRD_PARTY_LICENSES.html \ + THIRD_PARTY_JAVASCRIPT_LICENSES.json; do + [[ -s "${install_dir}/${installed}" ]] +done +[[ -x "${install_dir}/executor" ]] + +printf 'unexpected\n' > "${stage}/extra" +invalid_archive="${fixture}/invalid.tar.gz" +tar -C "$stage" -czf "$invalid_archive" \ + executor LICENSE THIRD_PARTY_LICENSES.html \ + THIRD_PARTY_JAVASCRIPT_LICENSES.json extra +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$invalid_archive" > "${invalid_archive}.sha256" +else + shasum -a 256 "$invalid_archive" > "${invalid_archive}.sha256" +fi + +if EXECUTOR_INSTALL_DIR="${fixture}/rejected" \ + "${repo_root}/scripts/install.sh" \ + --archive "$invalid_archive" \ + --no-modify-path >/dev/null 2>&1; then + printf 'installer accepted an archive with an unexpected member\n' >&2 + exit 1 +fi + +printf 'release archive installer fixture passed\n' diff --git a/src/api.rs b/src/api.rs index 55995175d..9c9b90735 100644 --- a/src/api.rs +++ b/src/api.rs @@ -11,7 +11,7 @@ use axum::{ ConnectInfo, DefaultBodyLimit, Extension, FromRequestParts, Path, State, rejection::JsonRejection, }, - http::{HeaderMap, HeaderValue, StatusCode, header, request::Parts}, + http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header, request::Parts}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get, post}, @@ -586,7 +586,8 @@ async fn request_id_middleware( ); response .headers_mut() - .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + .entry(header::CACHE_CONTROL) + .or_insert(HeaderValue::from_static("no-store")); response } @@ -1096,13 +1097,22 @@ async fn require_gateway_token( }) } -async fn not_found(Extension(request_id): Extension) -> ApiError { +async fn not_found( + Extension(request_id): Extension, + method: Method, + uri: Uri, + headers: HeaderMap, +) -> Response { + if let Some(response) = crate::web_assets::response(&method, &uri, &headers) { + return response; + } ApiError::new( &request_id, StatusCode::NOT_FOUND, "not_found", "The requested resource does not exist.", ) + .into_response() } async fn method_not_allowed(Extension(request_id): Extension) -> ApiError { diff --git a/src/web_assets.rs b/src/web_assets.rs index 194e8d3c6..6749b3813 100644 --- a/src/web_assets.rs +++ b/src/web_assets.rs @@ -1,10 +1,416 @@ -use std::path::Path; +use axum::{ + body::{Body, Bytes}, + http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}, + response::Response, +}; +use percent_encoding::percent_decode_str; -pub const WEB_DISTRIBUTION_DIRECTORY: &str = "web/build"; +struct EmbeddedAsset { + path: &'static str, + content: &'static [u8], + etag: &'static str, +} + +include!(concat!(env!("OUT_DIR"), "/embedded_web_assets.rs")); + +pub(crate) fn response( + method: &Method, + uri: &Uri, + request_headers: &HeaderMap, +) -> Option { + if !matches!(*method, Method::GET | Method::HEAD) { + return None; + } + + let path = normalized_path(uri.path())?; + if is_reserved_path(&path) { + return None; + } + + let exact_asset = find_asset(&path); + let (asset, spa_fallback) = match exact_asset { + Some(asset) => (asset, false), + None if can_use_spa_fallback(&path) => (find_asset("index.html")?, true), + None => return None, + }; + let etag = asset.etag; + let not_modified = request_headers + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + .is_some_and(|values| { + values + .split(',') + .map(str::trim) + .any(|candidate| candidate == "*" || candidate == etag) + }); + + let mut response = if not_modified { + Response::builder() + .status(StatusCode::NOT_MODIFIED) + .body(Body::empty()) + .expect("static response is valid") + } else { + let body = if *method == Method::HEAD { + Body::empty() + } else { + Body::from(Bytes::from_static(asset.content)) + }; + let mut response = Response::new(body); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&asset.content.len().to_string()) + .expect("asset lengths are valid headers"), + ); + response + }; + + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type(asset.path)), + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control(asset.path, spa_fallback)), + ); + headers.insert( + header::ETAG, + HeaderValue::from_str(etag).expect("SHA-256 ETags are valid headers"), + ); + add_security_headers(headers); + Some(response) +} + +fn normalized_path(path: &str) -> Option { + let raw_path = path.strip_prefix('/').unwrap_or(path); + let lowercase = raw_path.to_ascii_lowercase(); + if lowercase.contains("%2f") || lowercase.contains("%5c") { + return None; + } + let decoded = percent_decode_str(raw_path).decode_utf8().ok()?; + if decoded.contains('\\') || decoded.contains('\0') || decoded.contains('%') { + return None; + } + if decoded.is_empty() { + return Some("index.html".to_owned()); + } + + let segments = decoded.split('/').collect::>(); + if segments.iter().enumerate().any(|(index, segment)| { + *segment == "." || *segment == ".." || (segment.is_empty() && index + 1 != segments.len()) + }) { + return None; + } + Some(decoded.into_owned()) +} + +fn is_reserved_path(path: &str) -> bool { + ["api", "mcp", "healthz"].iter().any(|prefix| { + path == *prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + +fn can_use_spa_fallback(path: &str) -> bool { + path.rsplit('/') + .next() + .is_some_and(|segment| segment.is_empty() || !segment.contains('.')) +} + +fn find_asset(path: &str) -> Option<&'static EmbeddedAsset> { + EMBEDDED_WEB_ASSETS + .binary_search_by_key(&path, |asset| asset.path) + .ok() + .map(|index| &EMBEDDED_WEB_ASSETS[index]) +} + +fn content_type(path: &str) -> &'static str { + match path.rsplit_once('.').map(|(_, extension)| extension) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js" | "mjs") => "text/javascript; charset=utf-8", + Some("json" | "map") => "application/json; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("avif") => "image/avif", + Some("ico") => "image/x-icon", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("ttf") => "font/ttf", + Some("otf") => "font/otf", + Some("txt") => "text/plain; charset=utf-8", + Some("xml") => "application/xml; charset=utf-8", + Some("webmanifest") => "application/manifest+json; charset=utf-8", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +fn cache_control(path: &str, spa_fallback: bool) -> &'static str { + if !spa_fallback && path.starts_with("_app/immutable/") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + } +} + +fn add_security_headers(headers: &mut HeaderMap) { + headers.insert( + "content-security-policy", + HeaderValue::from_static(EMBEDDED_WEB_CSP), + ); + headers.insert( + "cross-origin-opener-policy", + HeaderValue::from_static("same-origin"), + ); + headers.insert( + "cross-origin-resource-policy", + HeaderValue::from_static("same-origin"), + ); + headers.insert( + "permissions-policy", + HeaderValue::from_static("camera=(), geolocation=(), microphone=()"), + ); + headers.insert("referrer-policy", HeaderValue::from_static("same-origin")); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use http_body_util::BodyExt; + use sha2::{Digest, Sha256}; + + use super::response; + + const FIXTURE_INDEX: &[u8] = include_bytes!("../tests/fixtures/web-assets/index.html"); + + #[tokio::test] + async fn serves_exact_assets_with_queries_and_immutable_cache_headers() { + let response = response( + &Method::GET, + &"/_app/immutable/entry/start.fixture.js?v=1" + .parse::() + .expect("valid URI"), + &HeaderMap::new(), + ) + .expect("fixture asset response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/javascript; charset=utf-8")) + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static( + "public, max-age=31536000, immutable" + )) + ); + assert_eq!( + response.headers().get("x-content-type-options"), + Some(&HeaderValue::from_static("nosniff")) + ); + let body = response + .into_body() + .collect() + .await + .expect("asset body") + .to_bytes(); + assert!(body.starts_with(b"document.documentElement")); + } + + #[tokio::test] + async fn head_reports_the_get_length_without_a_body() { + let response = response(&Method::HEAD, &Uri::from_static("/"), &HeaderMap::new()) + .expect("fixture index response"); + let length = response + .headers() + .get(header::CONTENT_LENGTH) + .expect("content length") + .clone(); + assert_ne!(length, HeaderValue::from_static("0")); + assert!( + response + .into_body() + .collect() + .await + .expect("HEAD body") + .to_bytes() + .is_empty() + ); + } + + #[tokio::test] + async fn client_routes_use_the_index_fallback_and_ignore_queries() { + let response = response( + &Method::GET, + &"/tools/example?view=detail" + .parse::() + .expect("valid URI"), + &HeaderMap::new(), + ) + .expect("SPA response"); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("no-cache")) + ); + let body = response + .into_body() + .collect() + .await + .expect("SPA body") + .to_bytes(); + assert!(body.windows(16).any(|window| window == b"Executor fixture")); + } + + #[test] + fn leaves_reserved_paths_methods_missing_files_and_unsafe_paths_to_the_api_fallback() { + for (method, path) in [ + (Method::GET, "/api/v1/missing"), + (Method::GET, "/mcp/missing"), + (Method::GET, "/healthz/missing"), + (Method::POST, "/tools"), + (Method::GET, "/missing.js"), + (Method::GET, "/.env"), + (Method::GET, "/_app/immutable/entry/start.fixture.js.map"), + (Method::GET, "/%2e%2e/index.html"), + (Method::GET, "/safe%2f..%2findex.html"), + (Method::GET, "/%252e%252e/index.html"), + (Method::GET, "/%252e%252e%252fsecret"), + ] { + assert!( + response( + &method, + &path.parse::().expect("valid test URI"), + &HeaderMap::new(), + ) + .is_none(), + "{method} {path} must not receive the SPA shell" + ); + } + } + + #[test] + fn static_responses_include_browser_security_headers() { + let response = response(&Method::GET, &Uri::from_static("/"), &HeaderMap::new()) + .expect("fixture index response"); + let headers = response.headers(); + + assert_eq!( + headers.get("x-content-type-options"), + Some(&HeaderValue::from_static("nosniff")) + ); + assert_eq!( + headers.get("x-frame-options"), + Some(&HeaderValue::from_static("DENY")) + ); + assert_eq!( + headers.get("cross-origin-opener-policy"), + Some(&HeaderValue::from_static("same-origin")) + ); + assert_eq!( + headers.get("cross-origin-resource-policy"), + Some(&HeaderValue::from_static("same-origin")) + ); + assert!(headers.contains_key("permissions-policy")); + assert!(headers.contains_key("referrer-policy")); + let csp = headers + .get("content-security-policy") + .expect("content security policy") + .to_str() + .expect("CSP is text"); + for directive in [ + "default-src 'none'", + "base-uri 'none'", + "connect-src 'self'", + "font-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "manifest-src 'self'", + "object-src 'none'", + "style-src 'self' 'unsafe-inline'", + "worker-src 'self'", + ] { + assert!( + csp.split("; ").any(|value| value == directive), + "{directive}" + ); + } + let script_source = csp + .split("; ") + .find(|directive| directive.starts_with("script-src ")) + .expect("script source directive"); + let actual_hashes = script_source + .split_ascii_whitespace() + .skip(2) + .map(str::to_owned) + .collect::>(); + assert_eq!(actual_hashes, fixture_script_hashes()); + assert!(!csp.contains("script-src 'self' 'unsafe-inline'")); + assert!(!csp.contains("'unsafe-eval'")); + } + + #[test] + fn matching_etag_returns_not_modified() { + let first = response(&Method::GET, &Uri::from_static("/"), &HeaderMap::new()) + .expect("initial response"); + let etag = first.headers().get(header::ETAG).expect("ETag").clone(); + let mut headers = HeaderMap::new(); + headers.insert(header::IF_NONE_MATCH, etag); + + let response = + response(&Method::GET, &Uri::from_static("/"), &headers).expect("conditional response"); + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert!(response.headers().get(header::CONTENT_LENGTH).is_none()); + } + + #[test] + fn etag_is_the_stable_build_time_digest_of_the_fixture() { + let expected = HeaderValue::from_str(&format!("\"{:x}\"", Sha256::digest(FIXTURE_INDEX))) + .expect("fixture digest is a valid ETag"); + for _ in 0..2 { + let response = response(&Method::GET, &Uri::from_static("/"), &HeaderMap::new()) + .expect("fixture index response"); + assert_eq!(response.headers().get(header::ETAG), Some(&expected)); + } + } -pub fn distribution_exists(repository_root: &Path) -> bool { - repository_root - .join(WEB_DISTRIBUTION_DIRECTORY) - .join("index.html") - .is_file() + fn fixture_script_hashes() -> BTreeSet { + let html = std::str::from_utf8(FIXTURE_INDEX).expect("fixture index is UTF-8"); + let mut remainder = html; + let mut hashes = BTreeSet::new(); + while let Some(open) = remainder.find("') + .map(|position| position + 1) + .expect("fixture script opener closes"); + let after_open = &after_open[body_start..]; + let body_end = after_open + .find("") + .expect("fixture script body closes"); + let digest = STANDARD.encode(Sha256::digest(&after_open.as_bytes()[..body_end])); + hashes.insert(format!("'sha256-{digest}'")); + remainder = &after_open[body_end + "".len()..]; + } + assert!( + !hashes.is_empty(), + "fixture must exercise CSP script hashes" + ); + hashes + } } diff --git a/tests/control_plane.rs b/tests/control_plane.rs index 4f1f6d60d..540eafa0c 100644 --- a/tests/control_plane.rs +++ b/tests/control_plane.rs @@ -1245,7 +1245,7 @@ async fn health_bootstrap_and_errors_have_stable_shapes() { json!({ "setupRequired": false, "authenticated": true }) ); - let missing = send_empty(executor.router(), Method::GET, "/missing", &[]).await; + let missing = send_empty(executor.router(), Method::GET, "/api/v1/missing", &[]).await; assert_error(missing, StatusCode::NOT_FOUND, "not_found").await; } diff --git a/tests/fixtures/web-assets/_app/immutable/assets/app.fixture.css b/tests/fixtures/web-assets/_app/immutable/assets/app.fixture.css new file mode 100644 index 000000000..da042281f --- /dev/null +++ b/tests/fixtures/web-assets/_app/immutable/assets/app.fixture.css @@ -0,0 +1,3 @@ +body { + color: #111827; +} diff --git a/tests/fixtures/web-assets/_app/immutable/entry/start.fixture.js b/tests/fixtures/web-assets/_app/immutable/entry/start.fixture.js new file mode 100644 index 000000000..fc985cbea --- /dev/null +++ b/tests/fixtures/web-assets/_app/immutable/entry/start.fixture.js @@ -0,0 +1 @@ +document.documentElement.dataset.executorFixture = "ready"; diff --git a/tests/fixtures/web-assets/index.html b/tests/fixtures/web-assets/index.html new file mode 100644 index 000000000..b014e42e1 --- /dev/null +++ b/tests/fixtures/web-assets/index.html @@ -0,0 +1,15 @@ + + + + + Executor fixture + + + +
Executor embedded web fixture
+ + + + diff --git a/tests/web_assets.rs b/tests/web_assets.rs new file mode 100644 index 000000000..043f09bc0 --- /dev/null +++ b/tests/web_assets.rs @@ -0,0 +1,141 @@ +use std::fs; + +use axum::{ + Router, + body::{Body, Bytes}, + http::{Method, Request, StatusCode, header}, + response::Response, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +const PRIVATE_MARKER: &str = "executor-private-data-must-never-be-served-91d94a"; + +#[tokio::test] +async fn app_router_preserves_static_policy_and_keeps_protocol_paths_json() { + let directory = tempfile::tempdir().expect("temporary data directory"); + let private_file = directory.path().join("private-marker"); + fs::write(&private_file, PRIVATE_MARKER).expect("private marker fixture is written"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test Executor opens"); + let router = app.router(); + + let asset = send( + router.clone(), + Method::GET, + "/_app/immutable/entry/start.fixture.js?v=1", + ) + .await; + assert_eq!(asset.status(), StatusCode::OK); + assert_eq!( + asset.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static( + "text/javascript; charset=utf-8" + )) + ); + assert_eq!( + asset.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static( + "public, max-age=31536000, immutable" + )) + ); + assert!(asset.headers().contains_key("x-request-id")); + assert!( + !body(asset) + .await + .windows(PRIVATE_MARKER.len()) + .any(|window| { window == PRIVATE_MARKER.as_bytes() }) + ); + + let spa = send(router.clone(), Method::GET, "/tools/example?view=detail").await; + assert_eq!(spa.status(), StatusCode::OK); + assert_eq!( + spa.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-cache")) + ); + assert!(spa.headers().contains_key("x-request-id")); + assert!( + body(spa) + .await + .windows(16) + .any(|window| window == b"Executor fixture") + ); + + for path in [ + "/api/v1/definitely-missing", + "/mcp/definitely-missing", + "/healthz/definitely-missing", + ] { + let response = send(router.clone(), Method::GET, path).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-store")), + "{path}" + ); + assert!( + response + .headers() + .get(header::CONTENT_TYPE) + .is_some_and(|value| value.as_bytes().starts_with(b"application/json")), + "{path}" + ); + let bytes = body(response).await; + assert!( + !bytes + .windows(16) + .any(|window| window == b"Executor fixture"), + "{path}" + ); + assert!( + !bytes + .windows(PRIVATE_MARKER.len()) + .any(|window| window == PRIVATE_MARKER.as_bytes()), + "{path}" + ); + } + + for path in [ + "/.env", + "/master.key", + "/executor.db", + "/%2e%2e/private-marker", + "/%252e%252e%252fprivate-marker", + ] { + let response = send(router.clone(), Method::GET, path).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + let bytes = body(response).await; + assert!( + !bytes + .windows(PRIVATE_MARKER.len()) + .any(|window| window == PRIVATE_MARKER.as_bytes()), + "{path} leaked a data-directory file" + ); + } + + app.shutdown().await; +} + +async fn send(router: Router, method: Method, uri: &str) -> Response { + router + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("test request is valid"), + ) + .await + .expect("router answers") +} + +async fn body(response: Response) -> Bytes { + response + .into_body() + .collect() + .await + .expect("response body collects") + .to_bytes() +} diff --git a/warden.toml b/warden.toml index ae63dfeae..6894f5789 100644 --- a/warden.toml +++ b/warden.toml @@ -17,9 +17,9 @@ ignorePaths = [ name = "wrdn-authz" remote = "getsentry/warden-skills" paths = [ - "apps/cloud/src/auth/**/*.ts", - "apps/cloud/src/api/**/*.ts", - "apps/cloud/src/routes/**/*.tsx", + "legacy/cloud/src/auth/**/*.ts", + "legacy/cloud/src/api/**/*.ts", + "legacy/cloud/src/routes/**/*.tsx", "packages/core/api/src/**/*.ts", ] @@ -39,8 +39,8 @@ paths = [ name = "wrdn-data-exfil" remote = "getsentry/warden-skills" paths = [ - "apps/cloud/src/api/**/*.ts", - "apps/cloud/src/routes/**/*.tsx", + "legacy/cloud/src/api/**/*.ts", + "legacy/cloud/src/routes/**/*.tsx", "apps/local/src/server/**/*.ts", "packages/core/storage-*/src/**/*.ts", "packages/plugins/**/src/**/*.ts", @@ -51,8 +51,8 @@ paths = [ name = "wrdn-pii" remote = "getsentry/warden-skills" paths = [ - "apps/cloud/src/**/*.ts", - "apps/cloud/src/**/*.tsx", + "legacy/cloud/src/**/*.ts", + "legacy/cloud/src/**/*.tsx", "apps/local/src/**/*.ts", "apps/local/src/**/*.tsx", "packages/core/storage-*/src/**/*.ts", diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md new file mode 100644 index 000000000..ee3cb019f --- /dev/null +++ b/web/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/web diff --git a/web/README.md b/web/README.md index 97223058b..9c10c1c59 100644 --- a/web/README.md +++ b/web/README.md @@ -1,8 +1,8 @@ # Executor web -The Svelte 5 and SvelteKit dashboard is a client-only SPA intended to be served by the Rust binary. -It talks to the Rust control API over the same origin. Static asset embedding and Rust fallback -serving are a separate packaging boundary and are not wired yet. +The Svelte 5 and SvelteKit dashboard is a client-only SPA served from the Rust binary. It talks to +the Rust control API over the same origin. Release builds embed the generated static files, so the +installed binary has no Node.js runtime dependency. ## Local checks @@ -16,8 +16,23 @@ bun run lint bun run test ``` -The static adapter is configured to write the production SPA to `web/build`. The future Rust -packaging step will embed that directory and serve `index.html` as the SPA fallback. +The static adapter writes the production SPA to `web/build`. Build the web output before compiling +the release binary: + +```sh +bun run --cwd web build +cargo build --release --locked +``` + +The Cargo build fails with an actionable error if the production assets are missing or malformed. +Normal checks and tests embed deterministic fixture assets instead, so they do not require a web +build. At runtime, the binary serves exact files with immutable caching and uses `index.html` only +for safe client-side navigation fallbacks. + +The Rust server sends a deny-by-default Content Security Policy. The packaging step derives exact +SHA-256 hashes for every inline script in the generated HTML, so arbitrary inline scripts remain +blocked. Inline styles stay allowed for generated attributes. Audit that style allowance against +the first approved production web build and replace it with hashes or nonces where practical. ## First boot diff --git a/web/src/lib/McpStdioSourceForm.svelte b/web/src/lib/McpStdioSourceForm.svelte index 61d1eef0a..27f634f00 100644 --- a/web/src/lib/McpStdioSourceForm.svelte +++ b/web/src/lib/McpStdioSourceForm.svelte @@ -200,7 +200,11 @@ {:else if templates.data?.length === 0}
No trusted local templates are configured. - Add templates through Executor's local CLI or configuration, then try again. + + Add templates to the machine-admin JSON registry selected by + --mcp-stdio-templates or EXECUTOR_MCP_STDIO_TEMPLATES_FILE, + restart Executor, then try again. +
diff --git a/web/src/lib/GraphqlSourceForm.test.ts b/web/src/lib/GraphqlSourceForm.test.ts index 0acd254a9..f56025b7e 100644 --- a/web/src/lib/GraphqlSourceForm.test.ts +++ b/web/src/lib/GraphqlSourceForm.test.ts @@ -153,6 +153,35 @@ describe("GraphQL source form", () => { expect(created).not.toHaveBeenCalled(); }); + it("keeps busy until the coordinator settles and focuses a recovery failure", async () => { + const response = deferred>(); + const create = vi.fn(() => response.promise); + const busy = vi.fn(); + render(GraphqlSourceForm, { + create, + onbusychange: busy, + oncreated: vi.fn(), + }); + await fillRequiredFields(); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(busy).toHaveBeenLastCalledWith(true)); + response.resolve({ + ok: false, + error: new ApiError({ + code: "source_create_recovery_pending", + displayMessage: "Source recovery is pending.", + requestId: null, + status: 0, + }), + }); + await response.promise; + await waitFor(() => expect(busy).toHaveBeenLastCalledWith(false)); + expect(screen.getByText("Source recovery is pending.")).toBeDefined(); + expect(document.activeElement).toBe(document.getElementById("graphql-source-error")); + expect(create).toHaveBeenCalledOnce(); + }); + it("clears secrets and focuses a rejected request", async () => { const create = vi.fn( async () => diff --git a/web/src/lib/McpCredentialEditor.svelte b/web/src/lib/McpCredentialEditor.svelte index 91e6dc25a..02cf1d5a5 100644 --- a/web/src/lib/McpCredentialEditor.svelte +++ b/web/src/lib/McpCredentialEditor.svelte @@ -25,7 +25,13 @@ source, disabled = false, onbusychange, - }: { source: Source; disabled?: boolean; onbusychange?: (busy: boolean) => void } = $props(); + onmutationchange, + }: { + source: Source; + disabled?: boolean; + onbusychange?: (busy: boolean) => void; + onmutationchange?: (busy: boolean) => void; + } = $props(); const auth = useAuthState(); const latest = createLatestRequest(); @@ -47,6 +53,7 @@ let saveController: AbortController | null = null; let lifetime = 0; let reportedBusy = false; + let reportedMutation = false; let httpCredential = $derived(buildMcpHttpCredential(httpDraft)); let stdioCredential = $derived(validateTemplateDraft(stdioFields, stdioSecrets)); @@ -57,6 +64,7 @@ cleanupLoad?.(); saveController?.abort(); if (reportedBusy) onbusychange?.(false); + if (reportedMutation) onmutationchange?.(false); }; }); @@ -68,15 +76,34 @@ }); $effect(() => { - if (!disabled) return; + if (saving === reportedMutation) return; + reportedMutation = saving; + onmutationchange?.(saving); + }); + + $effect(() => { + const isDisabled = disabled; untrack(() => { - cleanupLoad?.(); - cleanupLoad = null; - saveController?.abort(); - saveController = null; - loading = false; - saving = false; - clearSecretDrafts(); + if (!isDisabled) { + if (open && revision === null && !loading && error === null) loadEditor(); + return; + } + + if (cleanupLoad !== null) { + cleanupLoad(); + cleanupLoad = null; + loading = false; + revision = null; + error = null; + } + if (saveController !== null) { + saveController.abort(); + saveController = null; + saving = false; + revision = null; + error = null; + clearSecretDrafts(); + } }); }); diff --git a/web/src/lib/McpCredentialEditor.test.ts b/web/src/lib/McpCredentialEditor.test.ts index 293bbbffc..7154be864 100644 --- a/web/src/lib/McpCredentialEditor.test.ts +++ b/web/src/lib/McpCredentialEditor.test.ts @@ -14,6 +14,26 @@ function deferred() { return { promise, resolve }; } +function deferredDecodedResponse() { + const response = deferred(); + const settled = deferred(); + return { + promise: response.promise, + settled: settled.promise, + resolve(value: unknown) { + const decodedResponse = Response.json(value); + const read = decodedResponse.text.bind(decodedResponse); + decodedResponse.text = async () => { + const text = await read(); + // The next task starts after API decoding and component promise continuations settle. + setTimeout(() => settled.resolve(undefined), 0); + return text; + }; + response.resolve(decodedResponse); + }, + }; +} + function source(kind: "mcp_http" | "mcp_stdio"): Source { return { id: `${kind}-source`, @@ -44,14 +64,67 @@ afterEach(() => { }); describe("MCP credential editor", () => { - it("reports busy state and aborts an in-flight save when externally disabled", async () => { - const replacement = deferred(); + it("preserves an unsaved HTTP secret across a temporary disabled state", async () => { + const fetcher = vi.fn(async () => + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ); + vi.stubGlobal("fetch", fetcher); + const mounted = render(McpCredentialEditor, { source: source("mcp_http") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "still-unsaved" } }); + + await mounted.rerender({ source: source("mcp_http"), disabled: true }); + await waitFor(() => expect(token.closest("fieldset")?.hasAttribute("disabled")).toBe(true)); + expect(token.value).toBe("still-unsaved"); + + await mounted.rerender({ source: source("mcp_http"), disabled: false }); + await waitFor(() => expect(token.closest("fieldset")?.hasAttribute("disabled")).toBe(false)); + expect(token.value).toBe("still-unsaved"); + expect(fetcher).toHaveBeenCalledOnce(); + }); + + it("preserves an unsaved stdio secret across a temporary disabled state", async () => { + const fetcher = vi.fn(async (input) => { + if (String(input).endsWith("/credentials")) { + return Response.json({ + revision: 9, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + } + return Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }); + }); + vi.stubGlobal("fetch", fetcher); + const mounted = render(McpCredentialEditor, { source: source("mcp_stdio") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const secret = await screen.findByLabelText("TOKEN"); + await fireEvent.input(secret, { target: { value: "still-unsaved" } }); + + await mounted.rerender({ source: source("mcp_stdio"), disabled: true }); + await waitFor(() => expect(secret.closest("fieldset")?.hasAttribute("disabled")).toBe(true)); + expect(secret.value).toBe("still-unsaved"); + + await mounted.rerender({ source: source("mcp_stdio"), disabled: false }); + await waitFor(() => expect(secret.closest("fieldset")?.hasAttribute("disabled")).toBe(false)); + expect(secret.value).toBe("still-unsaved"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("aborts a submitted secret, reloads metadata, and ignores the late save completion", async () => { + const saveA = deferredDecodedResponse(); + const reloadB = deferredDecodedResponse(); const request = { signal: null as AbortSignal | null }; + let metadataRequests = 0; const onbusychange = vi.fn(); vi.stubGlobal( "fetch", vi.fn((_input, init) => { if (init?.method !== "PUT") { + metadataRequests += 1; + if (metadataRequests === 2) return reloadB.promise; return Promise.resolve( Response.json({ revision: 4, @@ -60,7 +133,7 @@ describe("MCP credential editor", () => { ); } request.signal = init.signal ?? null; - return replacement.promise; + return saveA.promise; }), ); const mounted = render(McpCredentialEditor, { @@ -69,10 +142,11 @@ describe("MCP credential editor", () => { }); await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); const token = await screen.findByLabelText("Bearer token"); + onbusychange.mockClear(); await fireEvent.input(token, { target: { value: "must-be-cleared" } }); await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); await waitFor(() => expect(request.signal).not.toBeNull()); - expect(onbusychange).toHaveBeenCalledWith(true); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true]])); await mounted.rerender({ source: source("mcp_http"), @@ -80,8 +154,133 @@ describe("MCP credential editor", () => { onbusychange, }); await waitFor(() => expect(request.signal?.aborted).toBe(true)); - expect(screen.getByLabelText("Bearer token").value).toBe(""); - await waitFor(() => expect(onbusychange).toHaveBeenLastCalledWith(false)); + await waitFor(() => expect(screen.queryByLabelText("Bearer token")).toBeNull()); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true], [false]])); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + + await mounted.rerender({ + source: source("mcp_http"), + disabled: false, + onbusychange, + }); + await waitFor(() => expect(metadataRequests).toBe(2)); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true], [false], [true]])); + reloadB.resolve({ + revision: 8, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await reloadB.settled; + const reloadedToken = await screen.findByLabelText("Bearer token"); + expect(reloadedToken.value).toBe(""); + await waitFor(() => + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]), + ); + + saveA.resolve({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await saveA.settled; + expect(screen.getByRole("button", { name: "Close credentials" })).toBeDefined(); + expect(screen.queryByRole("status")).toBeNull(); + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]); + }); + + it("reports only MCP credential writes as mutations and clears the fence on unmount", async () => { + const saveResponse = deferredDecodedResponse(); + const request = { signal: null as AbortSignal | null }; + vi.stubGlobal( + "fetch", + vi.fn((_input, init) => { + if (init?.method === "PUT") { + request.signal = init.signal ?? null; + return saveResponse.promise; + } + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ); + }), + ); + const onmutationchange = vi.fn(); + const mounted = render(McpCredentialEditor, { + source: source("mcp_http"), + onmutationchange, + }); + + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + expect(onmutationchange).not.toHaveBeenCalled(); + await fireEvent.input(token, { target: { value: "pending-secret" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(onmutationchange).toHaveBeenLastCalledWith(true)); + + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + expect(onmutationchange).toHaveBeenLastCalledWith(false); + saveResponse.resolve({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await saveResponse.settled; + }); + + it("retries an aborted metadata load and ignores its late completion", async () => { + const loadA = deferredDecodedResponse(); + const retryB = deferredDecodedResponse(); + const requests: AbortSignal[] = []; + const onbusychange = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn((_input, init) => { + if (init?.signal instanceof AbortSignal) requests.push(init.signal); + return requests.length === 1 ? loadA.promise : retryB.promise; + }), + ); + const mounted = render(McpCredentialEditor, { + source: source("mcp_http"), + onbusychange, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await waitFor(() => expect(requests).toHaveLength(1)); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true]])); + + await mounted.rerender({ + source: source("mcp_http"), + disabled: true, + onbusychange, + }); + await waitFor(() => expect(requests[0]?.aborted).toBe(true)); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true], [false]])); + + await mounted.rerender({ + source: source("mcp_http"), + disabled: false, + onbusychange, + }); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(screen.queryByLabelText("Username")).toBeNull(); + + retryB.resolve({ + revision: 7, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await retryB.settled; + await screen.findByLabelText("Bearer token"); + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]); + + loadA.resolve({ + revision: 3, + configuredSchemes: [{ name: "authorization", credentialType: "basic" }], + }); + await loadA.settled; + expect(screen.queryByLabelText("Username")).toBeNull(); + expect(screen.getByLabelText("Bearer token")).toBeDefined(); + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]); }); it("replaces HTTP API-key auth with the viewed CAS revision", async () => { diff --git a/web/src/lib/McpHttpSourceForm.svelte b/web/src/lib/McpHttpSourceForm.svelte index b025ff176..e1d09eb31 100644 --- a/web/src/lib/McpHttpSourceForm.svelte +++ b/web/src/lib/McpHttpSourceForm.svelte @@ -1,7 +1,7 @@ -
+
MCP Streamable HTTP

Connect a remote or local MCP server. Executor negotiates the protocol and imports its tool @@ -211,7 +221,7 @@ diff --git a/web/src/lib/McpHttpSourceForm.test.ts b/web/src/lib/McpHttpSourceForm.test.ts index 1497255c6..6d56db517 100644 --- a/web/src/lib/McpHttpSourceForm.test.ts +++ b/web/src/lib/McpHttpSourceForm.test.ts @@ -1,11 +1,9 @@ import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; -import { Effect, Schema } from "effect"; +import { ApiError, type ApiResult, type McpHttpSourceInput, type Source } from "./api"; import McpHttpSourceForm from "./McpHttpSourceForm.svelte"; -const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); - -function sourceFixture() { +function sourceFixture(): Source { return { id: "source-1", kind: "mcp_http", @@ -41,14 +39,13 @@ afterEach(() => { describe("MCP HTTP source form", () => { it("submits the stable source contract and clears the completed draft", async () => { - let submittedBody = ""; - const fetcher = vi.fn(async (_input, init) => { - submittedBody = String(init?.body); - return Response.json(sourceFixture(), { status: 201 }); + const inputs: McpHttpSourceInput[] = []; + const create = vi.fn(async (input: McpHttpSourceInput) => { + inputs.push(input); + return { ok: true, value: sourceFixture() } as const; }); - vi.stubGlobal("fetch", fetcher); const created = vi.fn(); - render(McpHttpSourceForm, { oncreated: created }); + render(McpHttpSourceForm, { create, oncreated: created }); await fireEvent.input(screen.getByLabelText("Endpoint"), { target: { value: "https://mcp.example.test/mcp" }, @@ -59,20 +56,21 @@ describe("MCP HTTP source form", () => { await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); await waitFor(() => expect(created).toHaveBeenCalledOnce()); - expect(decodeJson(submittedBody)).toEqual({ - kind: "mcp_http", - displayName: "Issue tracker", - endpoint: "https://mcp.example.test/mcp", - allowPrivateNetwork: false, - }); + expect(inputs).toEqual([ + { + kind: "mcp_http", + displayName: "Issue tracker", + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + }, + ]); expect(screen.getByLabelText("Endpoint").value).toBe(""); expect(screen.getByLabelText("Source name").value).toBe(""); }); it("blocks an obvious private endpoint until the operator opts in", async () => { - const fetcher = vi.fn(); - vi.stubGlobal("fetch", fetcher); - render(McpHttpSourceForm, { oncreated: vi.fn() }); + const create = vi.fn(); + render(McpHttpSourceForm, { create, oncreated: vi.fn() }); await fireEvent.input(screen.getByLabelText("Endpoint"), { target: { value: "http://127.42.0.1:7331/mcp" }, @@ -85,21 +83,18 @@ describe("MCP HTTP source form", () => { true, ); expect(screen.getByText(/Enable private network access/)).toBeDefined(); - expect(fetcher).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); }); it("rejects a completion after unmount and aborts the request", async () => { - const response = deferred(); + const response = deferred>(); const request = { signal: null as AbortSignal | null }; - vi.stubGlobal( - "fetch", - vi.fn((_input, init) => { - request.signal = init?.signal ?? null; - return response.promise; - }), - ); + const create = vi.fn((_input: McpHttpSourceInput, signal: AbortSignal) => { + request.signal = signal; + return response.promise; + }); const created = vi.fn(); - const mounted = render(McpHttpSourceForm, { oncreated: created }); + const mounted = render(McpHttpSourceForm, { create, oncreated: created }); await fireEvent.input(screen.getByLabelText("Endpoint"), { target: { value: "https://mcp.example.test/mcp" }, }); @@ -110,18 +105,27 @@ describe("MCP HTTP source form", () => { mounted.unmount(); expect(request.signal?.aborted).toBe(true); - response.resolve(Response.json(sourceFixture(), { status: 201 })); + response.resolve({ ok: true, value: sourceFixture() }); await response.promise; await Promise.resolve(); expect(created).not.toHaveBeenCalled(); }); - it("settles busy state and focuses the error after a network rejection", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Effect.runPromise(Effect.fail("offline"))), + it("clears the submitted secret and focuses a coordinator failure", async () => { + const busy = vi.fn(); + const create = vi.fn( + async () => + ({ + ok: false, + error: new ApiError({ + code: "source_create_recovery_pending", + displayMessage: "Source recovery is pending.", + requestId: null, + status: 0, + }), + }) as const, ); - render(McpHttpSourceForm, { oncreated: vi.fn() }); + render(McpHttpSourceForm, { create, oncreated: vi.fn(), onbusychange: busy }); await fireEvent.input(screen.getByLabelText("Endpoint"), { target: { value: "https://mcp.example.test/mcp" }, }); @@ -137,10 +141,8 @@ describe("MCP HTTP source form", () => { await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); await waitFor(() => expect(bearer.value).toBe("")); - expect(bearer.disabled).toBe(false); - expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( - true, - ); + expect(busy).toHaveBeenLastCalledWith(false); + expect(screen.getByText("Source recovery is pending.")).toBeDefined(); expect(document.activeElement).toBe(document.getElementById("mcp-http-error")); }); }); diff --git a/web/src/lib/McpStdioSourceForm.svelte b/web/src/lib/McpStdioSourceForm.svelte index 27f634f00..e873e2cf8 100644 --- a/web/src/lib/McpStdioSourceForm.svelte +++ b/web/src/lib/McpStdioSourceForm.svelte @@ -2,9 +2,10 @@ import { tick, untrack } from "svelte"; import ErrorNotice from "$lib/ErrorNotice.svelte"; import { - createMcpStdioSource, listMcpStdioTemplates, type ApiError, + type ApiResult, + type McpStdioSourceInput, type McpStdioTemplate, type Source, } from "$lib/api"; @@ -23,8 +24,17 @@ validateTemplateCatalog, validateTemplateDraft, } from "$lib/mcp-source-state"; - - let { oncreated }: { oncreated: (source: Source) => void } = $props(); + let { + create, + oncreated, + onbusychange, + disabled = false, + }: { + create: (input: McpStdioSourceInput, signal: AbortSignal) => Promise>; + oncreated?: (source: Source) => void; + onbusychange?: (busy: boolean) => void; + disabled?: boolean; + } = $props(); const auth = useAuthState(); const latestTemplates = createLatestRequest(); @@ -38,6 +48,7 @@ let error = $state(null); let activeController: AbortController | null = null; let lifetime = 0; + let reportedBusy = false; let currentTemplate = $derived( templates.data?.find((template) => template.name === selectedTemplate) ?? null, ); @@ -98,11 +109,19 @@ lifetime += 1; activeController?.abort(); activeController = null; + secretValues = {}; + if (reportedBusy) onbusychange?.(false); }; }); + $effect(() => { + if (busy === reportedBusy) return; + reportedBusy = busy; + onbusychange?.(busy); + }); + function selectTemplate(name: string | null) { - if (name === selectedTemplate) return; + if (disabled || name === selectedTemplate) return; selectedTemplate = name; secretValues = {}; error = null; @@ -112,7 +131,7 @@ event.preventDefault(); const templateName = selectedTemplate; const secrets = validatedSecrets; - if (busy || templateName === null || secrets === null) return; + if (busy || disabled || templateName === null || secrets === null) return; activeController?.abort(); const controller = new AbortController(); @@ -120,44 +139,36 @@ activeController = controller; busy = true; error = null; - const settled = await createMcpStdioSource( - { - kind: "mcp_stdio", - displayName: displayName.trim(), - ...(description.trim() ? { description: description.trim() } : {}), - templateName, - secretValues: secrets, - }, - undefined, - controller.signal, - ).then( - (result) => ({ ok: true, result }) as const, - () => ({ ok: false }) as const, + const input = { + kind: "mcp_stdio", + displayName: displayName.trim(), + ...(description.trim() ? { description: description.trim() } : {}), + templateName, + secretValues: secrets, + } satisfies McpStdioSourceInput; + secretValues = {}; + const result = await create(input, controller.signal).then( + (response) => response, + () => ({ ok: false, error: unexpectedRequestError() }) as const, ); if (owner !== lifetime || activeController !== controller || controller.signal.aborted) return; + activeController = null; busy = false; - if (!settled.ok) { - secretValues = {}; - error = unexpectedRequestError(); - await focusError(); - return; - } - if (!settled.result.ok) { - secretValues = {}; - if (!auth?.recoverFromApiError(settled.result.error)) { - error = settled.result.error; + if (!result.ok) { + if (!auth?.recoverFromApiError(result.error)) { + error = result.error; await focusError(); } return; } - const source = settled.result.value; + const source = result.value; displayName = ""; description = ""; secretValues = {}; - oncreated(source); + oncreated?.(source); } async function focusError() { @@ -175,7 +186,11 @@

{#if templates.data !== null && templates.data.length > 0 && !templates.stale} - {/if} @@ -184,7 +199,11 @@
Showing the last loaded template list while Executor reconnects. -
@@ -206,12 +225,16 @@ restart Executor, then try again. - {:else if templates.data !== null} -
+
Local process source