From c3f1f238d9db059900ac1eeb5bfdacefa6f8fd25 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 22 Sep 2026 16:55:05 -0700 Subject: [PATCH] Prepare am CLI 0.2.2 with Connected Local identity fixes ## Summary - Bind Connected Local token requests and memory scopes to the authenticated Cloud user. - Validate cached OAuth origin and issuer before using its identity, and preserve stored keys when identity lookup fails. - Add identity, scope, and token-request regression coverage. - Bump the Rust workspace to 0.2.2 and refresh Cargo.lock, including transitive dependency updates. ## Validation - Internal CI passed at ff57ef87377d6afd808ad6dc6a471361668fc34c, including Linux, macOS, Windows, MSRV, and cargo-deny checks. - Release version-bump validation passed for 0.2.1 to 0.2.2. - Public export dry-run passed for source 04e1a2d98f0568dfdf08e92cee0777f2b7335891. This snapshot prepares the CLI release; publishing binaries follows public CI and merge. Core 1.2.2 is unchanged. --- Cargo.lock | 411 +++++++++--------- Cargo.toml | 10 +- crates/cli/src/commands/client.rs | 332 +++++++++++++- crates/cli/src/commands/cloud_api_key.rs | 52 ++- .../commands/cloud_api_key_identity_tests.rs | 105 +++++ crates/cli/src/commands/connect.rs | 68 ++- crates/cli/src/commands/memory/mod.rs | 35 +- crates/cli/src/commands/memory/package.rs | 5 +- crates/cli/src/commands/memory/scope.rs | 147 +++++++ crates/cli/src/commands/migrate.rs | 117 ++++- crates/cli/src/hooks/run.rs | 6 +- crates/cli/src/verification/smoke.rs | 33 +- .../cli/src/verification/smoke_http_tests.rs | 89 +++- crates/cli/tests/local_token_scope.rs | 382 ++++++++++++++++ crates/cli/tests/support/local_token.rs | 307 +++++++++++++ crates/cloud-client/src/client.rs | 17 +- crates/cloud-client/tests/client.rs | 72 ++- crates/cloud-types/src/local_token.rs | 49 ++- crates/core-types/src/lib.rs | 27 ++ 19 files changed, 1962 insertions(+), 302 deletions(-) create mode 100644 crates/cli/src/commands/cloud_api_key_identity_tests.rs create mode 100644 crates/cli/tests/local_token_scope.rs create mode 100644 crates/cli/tests/support/local_token.rs diff --git a/Cargo.lock b/Cargo.lock index f330820..992b994 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,16 +4,16 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "am-cloud-client" -version = "0.2.1" +version = "0.2.2" dependencies = [ "am-cloud-types", "am-core-types", @@ -30,7 +30,7 @@ dependencies = [ [[package]] name = "am-cloud-types" -version = "0.2.1" +version = "0.2.2" dependencies = [ "am-core-types", "anyhow", @@ -47,7 +47,7 @@ dependencies = [ [[package]] name = "am-core-types" -version = "0.2.1" +version = "0.2.2" dependencies = [ "chrono", "serde", @@ -57,9 +57,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -132,13 +132,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -149,7 +149,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atomicmemory" -version = "0.2.1" +version = "0.2.2" dependencies = [ "am-cloud-client", "am-cloud-types", @@ -157,7 +157,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "chrono", "clap", "directories", @@ -283,11 +283,17 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -312,9 +318,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.0" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "jobserver", @@ -324,9 +330,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -336,9 +342,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", @@ -361,9 +367,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -371,9 +377,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -383,21 +389,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -416,9 +422,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -426,9 +432,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.4" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -460,9 +466,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -557,18 +563,18 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -596,7 +602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -607,9 +613,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "fnv" @@ -644,9 +650,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -659,9 +665,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -669,15 +675,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -686,38 +692,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -790,9 +796,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -802,9 +808,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -822,9 +828,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -847,18 +853,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -878,9 +884,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -897,7 +903,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -940,9 +946,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -954,9 +960,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -967,9 +973,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -981,16 +987,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1001,15 +1008,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1049,9 +1056,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -1074,9 +1081,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-docker" @@ -1170,9 +1177,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -1203,9 +1210,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -1224,9 +1231,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1239,15 +1246,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "matchers" @@ -1278,9 +1285,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -1293,7 +1300,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1329,9 +1336,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.4.0" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "is-wsl", "libc", @@ -1386,30 +1393,30 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] [[package]] name = "proc-macro-error-attr3" -version = "3.0.3" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5bfc63c4dc85083c9daaf7112d0261701d4058677c3bff7f2afc44e30ef3e1" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" dependencies = [ "proc-macro2", "quote", @@ -1417,14 +1424,14 @@ dependencies = [ [[package]] name = "proc-macro-error3" -version = "3.0.3" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0d42490f6b7b143eef32b9e3522e42bf25dadc02c69ed72236f80adb949b5c" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" dependencies = [ "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1438,9 +1445,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1458,9 +1465,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", @@ -1490,7 +1497,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1510,9 +1517,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -1545,11 +1552,10 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", "thiserror", ] @@ -1568,9 +1574,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1585,11 +1591,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "http", @@ -1648,12 +1654,12 @@ dependencies = [ [[package]] name = "rtoolbox" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1686,15 +1692,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1751,7 +1757,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1864,7 +1870,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1973,9 +1979,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -2018,9 +2024,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2038,13 +2044,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -2056,28 +2062,28 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", - "windows-sys 0.59.0", + "rustix 1.1.5", + "windows-sys 0.61.2", ] [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -2091,9 +2097,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2101,18 +2107,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -2133,20 +2130,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -2342,9 +2339,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-width" @@ -2415,9 +2412,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -2487,9 +2484,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2500,9 +2497,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -2510,9 +2507,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2520,31 +2517,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2575,7 +2572,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2744,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http", @@ -2762,9 +2759,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -2779,13 +2776,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -2800,13 +2797,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -2818,9 +2815,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2829,9 +2826,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2840,13 +2837,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fff7c7f..6ecbe5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ lto = "thin" codegen-units = 1 [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2024" rust-version = "1.88" license = "Apache-2.0" @@ -63,7 +63,7 @@ indicatif = "0.18" wiremock = "0.6.5" clap = { version = "4", features = ["derive", "env"] } fs4 = { version = "0.12.0", features = ["sync"] } -am-core-types = { path = "crates/core-types", version = "0.2.1" } -am-cloud-types = { path = "crates/cloud-types", version = "0.2.1" } -am-cloud-client = { path = "crates/cloud-client", version = "0.2.1" } -atomicmemory = { path = "crates/cli", version = "0.2.1" } +am-core-types = { path = "crates/core-types", version = "0.2.2" } +am-cloud-types = { path = "crates/cloud-types", version = "0.2.2" } +am-cloud-client = { path = "crates/cloud-client", version = "0.2.2" } +atomicmemory = { path = "crates/cli", version = "0.2.2" } diff --git a/crates/cli/src/commands/client.rs b/crates/cli/src/commands/client.rs index 347f833..b2da973 100644 --- a/crates/cli/src/commands/client.rs +++ b/crates/cli/src/commands/client.rs @@ -1,13 +1,17 @@ //! Shared client builders for command handlers. use am_cloud_client::{DashboardClient, MemoryClient}; -use anyhow::{Context, Result}; +use am_cloud_types::LocalTokenRequest; +use anyhow::{Context, Result, bail}; use url::Url; +use crate::auth::claims::decode_id_token; +use crate::auth::clerk_oauth::resolve_oauth_pair; +use crate::auth::origin::check_token_origin; use crate::auth::token::valid_bearer_token; use crate::cli::GlobalOptions; use crate::config::{ - DEFAULT_PROFILE, ENV_API_KEY, ENV_PROFILE, ProfileKind, ResolvedProfile, + DEFAULT_PROFILE, ENV_API_KEY, ENV_PROFILE, OAuthTokens, ProfileKind, ResolvedProfile, hosted_cloud_env_key_override_warning, hosted_cloud_managed_for_key_policy, is_cloud_api_key, load_config, load_credentials, local_profile_cloud_export_warning, require_api_key, resolve_core_api_key, resolve_profile, @@ -152,28 +156,156 @@ pub(crate) fn cloud_api_key_client_for_profile(profile: &ResolvedProfile) -> Res MemoryClient::new(base, api_key).map_err(Into::into) } +/// Build the Connected Local mint body from the authenticated Cloud session. +/// +/// Cloud `POST /v1/local/token` requires a JSON object with `memory_user_id` +/// set to a project member (Clerk user id). Missing/`null` bodies 422; an +/// empty object falls through to `default` and 403s for real projects. +pub(crate) async fn local_token_request_for_profile( + profile: &ResolvedProfile, +) -> Result { + let memory_user_id = memory_user_id_for_local_token(profile).await?; + Ok(LocalTokenRequest { memory_user_id }) +} + +/// Authorize a cached OAuth session for `target_base_url` before reading `sub`. +/// +/// `resolve_profile_from` copies oauth unbound; reading `sub` without this check +/// discloses the account id across origins and can mint for a non-member. +fn authorize_cached_oauth_for_target( + oauth: &OAuthTokens, + expected_issuer: &str, + target_base_url: &str, +) -> Result<()> { + check_token_origin( + oauth.api_origin.as_deref(), + oauth.issuer.as_deref(), + expected_issuer, + target_base_url, + ) +} + +fn sub_from_authorized_oauth( + oauth: &OAuthTokens, + expected_issuer: &str, + target_base_url: &str, +) -> Result> { + // Origin/issuer binding first — never return `sub` for a mismatched session. + authorize_cached_oauth_for_target(oauth, expected_issuer, target_base_url)?; + let Ok(claims) = decode_id_token(&oauth.id_token) else { + return Ok(None); + }; + let sub = claims.sub.trim(); + if sub.is_empty() { + return Ok(None); + } + Ok(Some(sub.to_string())) +} + +async fn memory_user_id_for_local_token(profile: &ResolvedProfile) -> Result { + // Prefer the already-resolved session when present (no network; `sub` is + // a stable identity, not a credential). Authorize it for this profile's + // Cloud origin first — skipping that check leaked production `sub` values + // to redirected `--base-url` targets. + if let Some(oauth) = &profile.oauth { + let config = load_config()?; + let (issuer, _) = resolve_oauth_pair(&config, &profile.base_url, None, None)?; + if let Some(sub) = sub_from_authorized_oauth(oauth, &issuer, &profile.base_url)? { + return Ok(sub); + } + } + + let token = valid_bearer_token(&profile.name, &profile.base_url) + .await + .context( + "Connected Local JWT mint requires a logged-in Cloud session — run `am auth login`", + )?; + let claims = decode_id_token(&token).context("decode logged-in id_token for memory_user_id")?; + let sub = claims.sub.trim(); + if sub.is_empty() { + bail!( + "logged-in id_token is missing sub (required as memory_user_id for POST /v1/local/token)" + ); + } + Ok(sub.to_string()) +} + +/// Health-only client: discards the minted JWT identity on purpose. +/// +/// `GET v1/memories/health` carries no `user_id`, so Core's user binding never +/// applies. Any caller that issues a user-scoped request (ingest, search, +/// list, get, delete) must go through `memory::memory_client_with_scope` or +/// bind the identity returned by `memory_client_for_profile` itself. pub async fn memory_client(global: &GlobalOptions) -> Result<(ResolvedProfile, MemoryClient)> { let profile = resolve_ctx(global).await?; - let client = memory_client_for_profile(&profile).await?; + // Health carries no `user_id`, so resolving the session identity on the + // key path would only add a refresh round trip and a spurious warning. + let (client, _) = build_memory_client(&profile, IdentityResolution::Skip).await?; Ok((profile, client)) } +/// Whether the key path should resolve the Connected Local identity. +#[derive(Clone, Copy, PartialEq, Eq)] +enum IdentityResolution { + Resolve, + Skip, +} + +/// Connected Local identity to bind into Core request scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopeIdentity { + /// Clerk `sub`: the default `user_id` for Core requests. + pub user: String, + /// True when Core enforces this identity. A Cloud-minted JWT carries + /// `memory_user_id`, and Core's `enforceMemoryUserBinding` returns 403 for + /// any other `user_id`, so a conflicting override must fail before the + /// request. False under a static `CORE_API_KEY`: Core applies no user + /// binding there, so `user` is only the default and an explicit override + /// is allowed (it is the only way to reach data written to another + /// namespace, e.g. `default`, before identity binding existed). + pub enforced: bool, +} + /// Build a memory client for an already-resolved profile. /// -/// Takes no `GlobalOptions` on purpose: without it there is nothing to resolve -/// from, so this cannot silently pick up a profile that changed since the -/// caller resolved. -pub(crate) async fn memory_client_for_profile(profile: &ResolvedProfile) -> Result { +/// Returns the Connected Local identity when one is available for scope +/// binding: enforced on the minted-JWT path, advisory when a managed or env +/// `CORE_API_KEY` authenticates HTTP. Callers that talk to Core must bind it +/// into request scope. Takes no `GlobalOptions` on purpose: without it there +/// is nothing to resolve from, so this cannot silently pick up a profile that +/// changed since the caller resolved. +pub(crate) async fn memory_client_for_profile( + profile: &ResolvedProfile, +) -> Result<(MemoryClient, Option)> { + build_memory_client(profile, IdentityResolution::Resolve).await +} + +async fn build_memory_client( + profile: &ResolvedProfile, + identity: IdentityResolution, +) -> Result<(MemoryClient, Option)> { + let key_path_identity = || async move { + match identity { + IdentityResolution::Resolve => key_path_scope_identity(profile).await, + IdentityResolution::Skip => None, + } + }; match profile.kind { ProfileKind::Cloud => { let api_key = require_api_key(profile)?; let base = Url::parse(&profile.base_url).context("parse base_url")?; - MemoryClient::new(base, api_key).context("create cloud memory client") + Ok(( + MemoryClient::new(base, api_key).context("create cloud memory client")?, + None, + )) } ProfileKind::Local => { let base = Url::parse(&profile.memory_base_url).context("parse local_url")?; if let Some(core_key) = resolve_core_api_key() { - return MemoryClient::new(base, core_key).context("create core memory client"); + return Ok(( + MemoryClient::new(base, core_key).context("create core memory client")?, + key_path_identity().await, + )); } // Prefer the managed container's persisted CORE_API_KEY over a Cloud-minted // JWT. Core rejects JWT for smoke / some local namespaces; reading the key @@ -183,15 +315,189 @@ pub(crate) async fn memory_client_for_profile(profile: &ResolvedProfile) -> Resu crate::instance::address::ManagedAddress::parse(&profile.memory_base_url)? .url() .parse()?; - return MemoryClient::new(managed_base, core_key) - .context("create core memory client"); + return Ok(( + MemoryClient::new(managed_base, core_key) + .context("create core memory client")?, + key_path_identity().await, + )); } // Pinned: cloud_api_key_client would resolve the active profile // again, so a "pinned" caller silently minted a token for whatever // profile was active by then. let cloud_client = cloud_api_key_client_for_profile(profile)?; - let token = cloud_client.mint_local_token().await?; - MemoryClient::new(base, token.access_token).context("create core memory client") + let req = local_token_request_for_profile(profile).await?; + let identity = ScopeIdentity { + user: req.memory_user_id.clone(), + enforced: true, + }; + let token = cloud_client.mint_local_token(&req).await?; + Ok(( + MemoryClient::new(base, token.access_token).context("create core memory client")?, + Some(identity), + )) + } + } +} + +/// Default scope identity when a static Core key authenticates HTTP. +/// +/// UTM-2's default `instance start --slm` authenticates with the persisted +/// `CORE_API_KEY`, so without this the key path wrote to `user_id=default` +/// while the JWT path wrote to the Clerk `sub`. With a session present, `sub` +/// becomes the default namespace (advisory: Core does not enforce it here). +/// +/// No session → `None`. A session that cannot be authorized for this profile +/// (origin/issuer mismatch, pre-binding login, failed refresh) must not break +/// a Core that the key already authenticates: fall back unbound and tell the +/// operator to re-link, instead of failing every memory command. +async fn key_path_scope_identity(profile: &ResolvedProfile) -> Option { + profile.oauth.as_ref()?; + match memory_user_id_for_local_token(profile).await { + Ok(user) => Some(ScopeIdentity { + user, + enforced: false, + }), + Err(err) => { + eprintln!("{}", unauthorized_session_warning(&err)); + None } } } + +fn unauthorized_session_warning(err: &anyhow::Error) -> String { + format!( + "warning: the Connected Local session on this profile could not be authorized ({err:#}); \ + continuing with the Core API key, unbound from your Cloud identity (requests use \ + `default` unless --scope-user or --user-id is given). Run `am auth login` to re-link." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::environment::Environment; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + fn id_token_with_sub(sub: &str) -> String { + let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"sub":"{sub}"}}"#).as_bytes()); + format!("hdr.{payload}.sig") + } + + fn oauth(sub: &str, api_origin: Option<&str>, issuer: Option<&str>) -> OAuthTokens { + OAuthTokens { + id_token: id_token_with_sub(sub), + refresh_token: None, + expires_at: None, + issuer: issuer.map(str::to_string), + api_origin: api_origin.map(str::to_string), + } + } + + /// Every production caller of `memory_client_for_profile` must bind the + /// minted identity it returns. Discarding it (`(client, _)` / `.0`) is how + /// smoke and export reintroduced the user_id/memory_user_id 403 after the + /// memory commands were fixed: Core's binding guard applies to all of them. + #[test] + fn minted_identity_is_never_discarded_by_callers() { + let roots = [ + ("commands/migrate.rs", include_str!("migrate.rs")), + ( + "verification/smoke.rs", + include_str!("../verification/smoke.rs"), + ), + ("commands/memory/mod.rs", include_str!("memory/mod.rs")), + ]; + for (name, src) in roots { + let production = src.split("#[cfg(test)]").next().unwrap(); + let mut calls = 0; + for (idx, line) in production.lines().enumerate() { + let t = line.trim_start(); + if t.starts_with("//") || !t.contains("memory_client_for_profile(") { + continue; + } + calls += 1; + // The call plus the statement it belongs to (multi-line `?` chains). + let window: String = production + .lines() + .skip(idx.saturating_sub(1)) + .take(16) + .collect::>() + .join("\n"); + assert!( + !window.contains(", _) = memory_client_for_profile"), + "{name}:{}: minted identity discarded with `(client, _)`", + idx + 1 + ); + assert!( + !window.contains("\n .0;") && !window.contains(")?.0"), + "{name}:{}: minted identity discarded with `.0`", + idx + 1 + ); + } + assert!( + calls > 0, + "{name}: expected a memory_client_for_profile call site" + ); + } + } + + #[test] + fn unauthorized_session_warning_names_the_fix() { + let warning = unauthorized_session_warning(&anyhow::anyhow!("session acquired for X")); + assert!(warning.starts_with("warning: "), "{warning}"); + assert!(warning.contains("session acquired for X"), "{warning}"); + assert!(warning.contains("`am auth login`"), "{warning}"); + } + + #[test] + fn cached_oauth_matching_origin_yields_sub() { + let tokens = oauth( + "user_member", + Some(Environment::PROD_BASE_URL), + Some(Environment::PROD_OAUTH_ISSUER), + ); + let sub = sub_from_authorized_oauth( + &tokens, + Environment::PROD_OAUTH_ISSUER, + Environment::PROD_BASE_URL, + ) + .unwrap() + .expect("sub"); + assert_eq!(sub, "user_member"); + } + + #[test] + fn cached_oauth_mismatched_origin_refuses_sub() { + let tokens = oauth( + "user_member", + Some(Environment::PROD_BASE_URL), + Some(Environment::PROD_OAUTH_ISSUER), + ); + let err = sub_from_authorized_oauth( + &tokens, + "https://clerk.custom.example", + "https://api.custom.example", + ) + .expect_err("must not disclose sub across origins"); + assert!( + err.to_string().contains("acquired for") || err.to_string().contains("Refusing"), + "{err}" + ); + } + + #[test] + fn cached_oauth_missing_origin_refuses_sub() { + let tokens = oauth("user_member", None, Some(Environment::PROD_OAUTH_ISSUER)); + let err = sub_from_authorized_oauth( + &tokens, + Environment::PROD_OAUTH_ISSUER, + Environment::PROD_BASE_URL, + ) + .expect_err("unbound sessions must fail closed"); + assert!( + err.to_string().contains("predates Cloud-origin binding"), + "{err}" + ); + } +} diff --git a/crates/cli/src/commands/cloud_api_key.rs b/crates/cli/src/commands/cloud_api_key.rs index 2ada29e..21aa7fe 100644 --- a/crates/cli/src/commands/cloud_api_key.rs +++ b/crates/cli/src/commands/cloud_api_key.rs @@ -12,7 +12,9 @@ use url::Url; use crate::auth::origin::same_origin; use crate::cli::GlobalOptions; -use crate::commands::client::{cloud_api_key_client, dashboard_client}; +use crate::commands::client::{ + cloud_api_key_client, dashboard_client, local_token_request_for_profile, +}; use crate::config::{ ResolvedProfile, is_cloud_api_key, machine_scoped_key_name, require_api_key, require_project_id, store_api_key, @@ -181,7 +183,7 @@ pub async fn ensure_connected_local_cloud_api_key( && is_cloud_api_key(&key) { if stored_key_probe_allows_reuse( - probe_cloud_api_key_mint(&profile.base_url, &key).await, + probe_cloud_api_key_mint(profile, &key).await, profile.project_id.as_deref(), &project_id, )? { @@ -211,7 +213,7 @@ pub async fn ensure_connected_local_cloud_api_key( |secret| store_api_key(&profile.name, secret, &profile.base_url, &project_id), ) .await?; - probe_cloud_api_key_mint(&profile.base_url, &secret) + probe_cloud_api_key_mint(profile, &secret) .await .context("verify newly provisioned Cloud API key (POST /v1/local/token)")?; if let Some(msg) = outcome.operator_message() { @@ -228,9 +230,12 @@ pub async fn ensure_connected_local_cloud_api_key_stored( project_id: &str, ) -> Result { if let Ok((resolved, client)) = cloud_api_key_client(global).await { + // Identity failures must not fall through to rotate/create — that would + // invalidate a still-valid stored secret without ever probing mint. + let req = local_token_request_for_stored_key_probe(&resolved).await?; // The active profile can differ from the project being provisioned. if stored_key_probe_allows_reuse( - client.mint_local_token().await.map(|_| ()), + client.mint_local_token(&req).await.map(|_| ()), resolved.project_id.as_deref(), project_id, )? { @@ -255,6 +260,18 @@ pub async fn ensure_connected_local_cloud_api_key_stored( Ok(outcome) } +/// Resolve mint identity for the stored-key reuse probe. +/// +/// Failures preserve the stored key: callers must `?` this before create/rotate. +async fn local_token_request_for_stored_key_probe( + profile: &ResolvedProfile, +) -> Result { + local_token_request_for_profile(profile).await.context( + "resolve memory_user_id for stored-key probe: stored key preserved; \ + identity lookup failed, so setup cannot continue", + ) +} + async fn rotate_or_create_runtime_key( client: &dyn ConnectedLocalCredentialBackend, project_id: &str, @@ -357,10 +374,16 @@ fn cloud_key_create_context(err: &CloudClientError, key_name: &str, api_origin: } } -async fn probe_cloud_api_key_mint(base_url: &str, api_key: &str) -> Result<(), CloudClientError> { - let base = Url::parse(base_url)?; +async fn probe_cloud_api_key_mint( + profile: &ResolvedProfile, + api_key: &str, +) -> Result<(), CloudClientError> { + let base = Url::parse(&profile.base_url)?; let client = MemoryClient::new(base, api_key)?; - client.mint_local_token().await.map(|_| ()) + let req = local_token_request_for_profile(profile) + .await + .map_err(|err| CloudClientError::Validation(err.to_string()))?; + client.mint_local_token(&req).await.map(|_| ()) } #[cfg(test)] @@ -370,6 +393,8 @@ mod tests { use super::*; use am_cloud_types::ApiKeyWithSecret; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::{TimeZone, Utc}; const TEST_LOCAL_KEY_NAME: &str = "connected-local-runtime-a1b2c3d4e5f6"; @@ -726,6 +751,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"user_probe_test"}"#); let profile = ResolvedProfile { name: "probe-test".into(), base_url: format!("http://{address}"), @@ -733,7 +759,13 @@ mod tests { project_id: Some("proj_test".into()), memory_base_url: format!("http://{address}"), api_key: Some("amc_stored_secret".into()), - oauth: None, + oauth: Some(crate::config::OAuthTokens { + id_token: format!("hdr.{payload}.sig"), + refresh_token: None, + expires_at: None, + issuer: None, + api_origin: Some(format!("http://{address}")), + }), }; let result = ensure_connected_local_cloud_api_key(&GlobalOptions::default(), &profile).await; @@ -803,3 +835,7 @@ mod tests { assert!(!is_api_key_quota_exceeded(&other)); } } + +#[cfg(all(test, unix))] +#[path = "cloud_api_key_identity_tests.rs"] +mod identity_tests; diff --git a/crates/cli/src/commands/cloud_api_key_identity_tests.rs b/crates/cli/src/commands/cloud_api_key_identity_tests.rs new file mode 100644 index 0000000..2ae099b --- /dev/null +++ b/crates/cli/src/commands/cloud_api_key_identity_tests.rs @@ -0,0 +1,105 @@ +//! Exercise the real stored-key entry point in a process with isolated credentials. + +#[path = "../../tests/support/local_token.rs"] +mod support; + +use super::{ProvisionOutcome, ensure_connected_local_cloud_api_key_stored}; +use crate::cli::GlobalOptions; +use support::Fixture; + +const CHILD: &str = "AM_TEST_STORED_KEY_CHILD"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn identity_lookup_failure_preserves_valid_key() { + if std::env::var_os(CHILD).is_some() { + let result = ensure_connected_local_cloud_api_key_stored( + &GlobalOptions::default(), + "local", + "proj_test", + ) + .await; + let error = result.expect_err("identity failure must not provision a key"); + assert!(format!("{error:#}").contains("stored key preserved")); + return; + } + let fixture = Fixture::new().await; + fixture.edit("config.toml", |value| { + value["profiles"]["local"] + .as_table_mut() + .unwrap() + .remove("oauth_ref"); + }); + fixture.edit("credentials.toml", |value| { + value["oauth"]["cloud"]["expires_at"] = 0.into(); + }); + fixture.api.lock().unwrap().fail_first_discovery = true; + let before = std::fs::read(fixture.config.join("credentials.toml")).unwrap(); + let output = fixture + .command(&std::env::current_exe().unwrap()) + .env(CHILD, "1") + .args([ + "--exact", + "commands::cloud_api_key::identity_tests::identity_lookup_failure_preserves_valid_key", + "--nocapture", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fixture.requests().len(), + 1, + "must not retry auth then rotate an unprobed key" + ); + assert_eq!( + fixture.requests()[0].path, + "/.well-known/oauth-authorization-server" + ); + assert_eq!( + before, + std::fs::read(fixture.config.join("credentials.toml")).unwrap() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn successful_mint_reuses_key_without_mutation() { + if std::env::var_os(CHILD).is_some() { + let outcome = ensure_connected_local_cloud_api_key_stored( + &GlobalOptions::default(), + "local", + "proj_test", + ) + .await + .unwrap(); + assert_eq!(outcome, ProvisionOutcome::Reused); + return; + } + let fixture = Fixture::new().await; + let before = std::fs::read(fixture.config.join("credentials.toml")).unwrap(); + let output = fixture + .command(&std::env::current_exe().unwrap()) + .env(CHILD, "1") + .args([ + "--exact", + "commands::cloud_api_key::identity_tests::successful_mint_reuses_key_without_mutation", + "--nocapture", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(fixture.requests().len(), 1); + assert_eq!(fixture.requests()[0].path, "/v1/local/token"); + assert_eq!( + before, + std::fs::read(fixture.config.join("credentials.toml")).unwrap() + ); +} diff --git a/crates/cli/src/commands/connect.rs b/crates/cli/src/commands/connect.rs index c239119..18fc97d 100644 --- a/crates/cli/src/commands/connect.rs +++ b/crates/cli/src/commands/connect.rs @@ -10,7 +10,10 @@ use serde::Serialize; use crate::auth::token::valid_bearer_token; use crate::cli::GlobalOptions; -use crate::commands::client::{cloud_api_key_client, dashboard_client, memory_client, resolve_ctx}; +use crate::commands::client::{ + cloud_api_key_client, dashboard_client, local_token_request_for_profile, memory_client, + resolve_ctx, +}; use crate::commands::connect_project::{ConnectProjectOptions, run as run_connect_project}; use crate::commands::local_clients::{ KeyProvenance, redact_secret, render_client_env_block, resolve_local_clients, @@ -300,7 +303,8 @@ async fn run_token(global: &GlobalOptions, print_token: bool) -> Result<()> { let profile = resolve_ctx(global).await?; ensure_local_profile(&profile)?; let (_profile, client) = cloud_api_key_client(global).await?; - let token = client.mint_local_token().await?; + let req = local_token_request_for_profile(&profile).await?; + let token = client.mint_local_token(&req).await?; eprintln!("warning: token printed to stdout; avoid logging or piping to files"); println!("{}", token.access_token); Ok(()) @@ -483,28 +487,44 @@ async fn check_jwks_reachable(cloud_base_url: &str) -> DoctorCheck { async fn check_mint_token(global: &GlobalOptions) -> DoctorCheck { match cloud_api_key_client(global).await { - Ok((_p, client)) => match client.mint_local_token().await { - Ok(token) if !token.access_token.is_empty() => DoctorCheck { - name: "jwt_mint".into(), - status: "pass".into(), - message: format!("Cloud minted Core JWT (expires_in={}s)", token.expires_in), - hint: None, - }, - Ok(_) => DoctorCheck { - name: "jwt_mint".into(), - status: "fail".into(), - message: "mint returned empty access_token".into(), - hint: None, - }, - Err(err) => DoctorCheck { - name: "jwt_mint".into(), - status: "fail".into(), - message: err.to_string(), - hint: Some( - "ensure project is type=local and Cloud API key belongs to that project".into(), - ), - }, - }, + Ok((profile, client)) => { + let req = match local_token_request_for_profile(&profile).await { + Ok(req) => req, + Err(err) => { + return DoctorCheck { + name: "jwt_mint".into(), + status: "fail".into(), + message: err.to_string(), + hint: Some( + "run `am auth login` so Connected Local can bind memory_user_id".into(), + ), + }; + } + }; + match client.mint_local_token(&req).await { + Ok(token) if !token.access_token.is_empty() => DoctorCheck { + name: "jwt_mint".into(), + status: "pass".into(), + message: format!("Cloud minted Core JWT (expires_in={}s)", token.expires_in), + hint: None, + }, + Ok(_) => DoctorCheck { + name: "jwt_mint".into(), + status: "fail".into(), + message: "mint returned empty access_token".into(), + hint: None, + }, + Err(err) => DoctorCheck { + name: "jwt_mint".into(), + status: "fail".into(), + message: err.to_string(), + hint: Some( + "ensure project is type=local and Cloud API key belongs to that project" + .into(), + ), + }, + } + } Err(err) => DoctorCheck { name: "jwt_mint".into(), status: "fail".into(), diff --git a/crates/cli/src/commands/memory/mod.rs b/crates/cli/src/commands/memory/mod.rs index 54060b6..22c59bd 100644 --- a/crates/cli/src/commands/memory/mod.rs +++ b/crates/cli/src/commands/memory/mod.rs @@ -8,8 +8,8 @@ use anyhow::Result; use clap::Subcommand; use crate::cli::GlobalOptions; -use crate::commands::client::memory_client; -use crate::config::{ProfileKind, resolve_profile}; +use crate::commands::client::{memory_client_for_profile, resolve_ctx}; +use crate::config::{ProfileKind, ResolvedProfile, resolve_profile}; use crate::envelope::EmitContext; use crate::output::emit_command; use crate::telemetry::{ActivationContext, capture_first_real_memory_if_needed}; @@ -17,7 +17,26 @@ use crate::validation::with_operation_recovery; use ingest::{ContentClass, SdkIngestMode, build_ingest_request}; use package::{PackageFormat, PackageSection, run_package}; -use scope::{NamespaceSupport, resolve_memory_scope_with}; +use scope::{MemoryScope, NamespaceSupport, bind_memory_user, resolve_memory_scope_with}; + +/// Resolve a memory client and bind Connected Local identity into `scope.user_id`. +/// +/// When a Connected Local session is present, Core requests default to the +/// Clerk `sub`. Under a Cloud-minted JWT that identity is enforced and a +/// conflicting `--scope-user` fails; under a static `CORE_API_KEY` an explicit +/// `--scope-user` overrides it. Pure Core-key / Cloud paths without a session +/// leave scope unchanged. +pub async fn memory_client_with_scope( + global: &GlobalOptions, + mut scope: MemoryScope, +) -> Result<(ResolvedProfile, am_cloud_client::MemoryClient, MemoryScope)> { + let profile = resolve_ctx(global).await?; + let (client, identity) = memory_client_for_profile(&profile).await?; + if let Some(identity) = identity.as_ref() { + bind_memory_user(&mut scope, identity, global.scope_user.as_deref())?; + } + Ok((profile, client, scope)) +} #[derive(Debug, Subcommand)] pub enum MemoryCommand { @@ -194,6 +213,7 @@ async fn run_ingest( NamespaceSupport::Unsupported, )?; let parsed_metadata = parse_metadata(metadata)?; + let (profile, client, scope) = memory_client_with_scope(global, scope).await?; let (req, is_verbatim) = build_ingest_request( mode, &scope, @@ -205,7 +225,6 @@ async fn run_ingest( stdin, ) .await?; - let (profile, client) = memory_client(global).await?; let resp = if is_verbatim { client .ingest_quick(&req) @@ -251,7 +270,7 @@ async fn run_search( ) -> Result<()> { let scope = resolve_memory_scope_with(global, session, agent_id, None, NamespaceSupport::Supported)?; - let (_profile, client) = memory_client(global).await?; + let (_profile, client, scope) = memory_client_with_scope(global, scope).await?; let req = am_core_types::CoreSearchRequest { user_id: scope.user_id, query, @@ -285,7 +304,7 @@ async fn run_list( ) -> Result<()> { let scope = resolve_memory_scope_with(global, session, None, None, NamespaceSupport::Unsupported)?; - let (_profile, client) = memory_client(global).await?; + let (_profile, client, scope) = memory_client_with_scope(global, scope).await?; let query = am_core_types::CoreListMemoriesQuery { user_id: scope.user_id, limit, @@ -303,7 +322,7 @@ async fn run_list( async fn run_get(global: &GlobalOptions, memory_id: String) -> Result<()> { let scope = resolve_memory_scope_with(global, None, None, None, NamespaceSupport::Unsupported)?; - let (_profile, client) = memory_client(global).await?; + let (_profile, client, scope) = memory_client_with_scope(global, scope).await?; let query = am_core_types::CoreMemoryQuery { user_id: scope.user_id, workspace_id: scope.workspace_id, @@ -316,7 +335,7 @@ async fn run_get(global: &GlobalOptions, memory_id: String) -> Result<()> { async fn run_delete(global: &GlobalOptions, memory_id: String) -> Result<()> { let scope = resolve_memory_scope_with(global, None, None, None, NamespaceSupport::Unsupported)?; - let (_profile, client) = memory_client(global).await?; + let (_profile, client, scope) = memory_client_with_scope(global, scope).await?; let query = am_core_types::CoreMemoryQuery { user_id: scope.user_id, workspace_id: scope.workspace_id, diff --git a/crates/cli/src/commands/memory/package.rs b/crates/cli/src/commands/memory/package.rs index 2b4d482..efba8dc 100644 --- a/crates/cli/src/commands/memory/package.rs +++ b/crates/cli/src/commands/memory/package.rs @@ -6,8 +6,7 @@ use serde::Serialize; use am_core_types::CoreSearchRequest; use crate::cli::GlobalOptions; -use crate::commands::client::memory_client; -use crate::commands::memory::scope::resolve_memory_scope; +use crate::commands::memory::{memory_client_with_scope, scope::resolve_memory_scope}; use crate::envelope::EmitContext; use crate::output::emit_command; @@ -85,7 +84,7 @@ pub async fn run_package( bail!("package requires a query"); } let scope = resolve_memory_scope(global, session, agent_id, workspace)?; - let (_profile, client) = memory_client(global).await?; + let (_profile, client, scope) = memory_client_with_scope(global, scope).await?; let req = CoreSearchRequest { user_id: scope.user_id, query: query.clone(), diff --git a/crates/cli/src/commands/memory/scope.rs b/crates/cli/src/commands/memory/scope.rs index 60bcf1f..00d958c 100644 --- a/crates/cli/src/commands/memory/scope.rs +++ b/crates/cli/src/commands/memory/scope.rs @@ -24,6 +24,7 @@ use anyhow::{Result, bail}; use uuid::Uuid; use crate::cli::GlobalOptions; +use crate::commands::client::ScopeIdentity; #[derive(Debug, Clone)] pub struct MemoryScope { @@ -131,11 +132,58 @@ pub fn resolve_memory_scope_with( }) } +/// Bind a Connected Local identity into memory request scope. +/// +/// An unset `--scope-user` takes the identity instead of `"default"`. +/// Under an enforced identity (Cloud-minted JWT) Core's +/// `enforceMemoryUserBinding` rejects any other `user_id`, so a conflicting +/// override fails closed before the request. Under a static Core key Core +/// applies no binding, so an explicit override is the operator's choice and +/// wins. +pub fn bind_memory_user( + scope: &mut MemoryScope, + identity: &ScopeIdentity, + explicit_scope_user: Option<&str>, +) -> Result<()> { + let user = identity.user.trim(); + if user.is_empty() { + bail!("Connected Local identity is empty — run `am auth login` and retry"); + } + match explicit_scope_user.map(str::trim).filter(|s| !s.is_empty()) { + Some(explicit) if explicit != user => { + if identity.enforced { + bail!( + "--scope-user conflicts with Connected Local JWT identity.\n\ + Omit --scope-user to use the logged-in identity, or pass --scope-user matching that identity." + ); + } + scope.user_id = explicit.to_string(); + } + _ => scope.user_id = user.to_string(), + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; const AGENT: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + const MINTED: &str = "user_member"; + + fn jwt() -> ScopeIdentity { + ScopeIdentity { + user: MINTED.into(), + enforced: true, + } + } + + fn managed_key() -> ScopeIdentity { + ScopeIdentity { + user: MINTED.into(), + enforced: false, + } + } fn global_with(user: Option<&str>, thread: Option<&str>) -> GlobalOptions { GlobalOptions { @@ -145,6 +193,105 @@ mod tests { } } + fn default_scope() -> MemoryScope { + resolve_memory_scope(&global_with(None, None), None, None, None).unwrap() + } + + #[test] + fn jwt_bind_replaces_default_user_when_no_override() { + let mut scope = default_scope(); + assert_eq!(scope.user_id, "default"); + bind_memory_user(&mut scope, &jwt(), None).unwrap(); + assert_eq!(scope.user_id, MINTED); + } + + #[test] + fn jwt_bind_allows_matching_explicit_override() { + let mut scope = + resolve_memory_scope(&global_with(Some(MINTED), None), None, None, None).unwrap(); + bind_memory_user(&mut scope, &jwt(), Some(MINTED)).unwrap(); + assert_eq!(scope.user_id, MINTED); + } + + #[test] + fn jwt_bind_rejects_conflicting_explicit_override() { + let mut scope = + resolve_memory_scope(&global_with(Some("other"), None), None, None, None).unwrap(); + let err = bind_memory_user(&mut scope, &jwt(), Some("other")) + .expect_err("conflicting --scope-user must fail closed") + .to_string(); + assert!( + err.contains("conflicts with Connected Local JWT identity"), + "{err}" + ); + assert_eq!(scope.user_id, "other"); + } + + #[test] + fn managed_key_defaults_to_session_identity() { + let mut scope = default_scope(); + bind_memory_user(&mut scope, &managed_key(), None).unwrap(); + assert_eq!(scope.user_id, MINTED); + } + + #[test] + fn managed_key_allows_explicit_override() { + // Core applies no user binding under a static key, so an explicit + // namespace (e.g. data written to `default` before binding existed) + // must stay reachable. + let mut scope = + resolve_memory_scope(&global_with(Some("default"), None), None, None, None).unwrap(); + bind_memory_user(&mut scope, &managed_key(), Some(" default ")).unwrap(); + assert_eq!(scope.user_id, "default"); + } + + #[test] + fn empty_identity_fails_closed_on_both_paths() { + for enforced in [true, false] { + let identity = ScopeIdentity { + user: " ".into(), + enforced, + }; + assert!(bind_memory_user(&mut default_scope(), &identity, None).is_err()); + } + } + + /// Every memory→Core surface that resolves scope must bind JWT identity + /// through `memory_client_with_scope`. A sibling that calls `memory_client` + /// after `resolve_memory_scope*` without the binder reintroduces the + /// user_id=default / minted-sub mismatch. + #[test] + fn jwt_scope_binder_covers_memory_and_hook_surfaces() { + let roots = [ + ("mod.rs", include_str!("mod.rs")), + ("package.rs", include_str!("package.rs")), + ("hooks/run.rs", include_str!("../../hooks/run.rs")), + ]; + for (name, src) in roots { + let production = src + .split("#[cfg(test)]") + .next() + .expect("production source before tests"); + let unbound = production + .lines() + .filter(|line| { + let t = line.trim_start(); + !t.starts_with("//") + && (t.contains("memory_client(global)") + || t.contains("memory_client(&global)")) + }) + .count(); + assert_eq!( + unbound, 0, + "{name}: memory/hook surfaces must use memory_client_with_scope, not bare memory_client" + ); + assert!( + production.contains("memory_client_with_scope"), + "{name}: expected memory_client_with_scope in JWT-bound surface" + ); + } + } + #[test] fn user_only_does_not_set_session() { let scope = diff --git a/crates/cli/src/commands/migrate.rs b/crates/cli/src/commands/migrate.rs index fcd0303..ff83640 100644 --- a/crates/cli/src/commands/migrate.rs +++ b/crates/cli/src/commands/migrate.rs @@ -7,6 +7,8 @@ use std::path::{Path, PathBuf}; /// Records per import request. The Cloud API rejects requests above this, and /// export paginates by the same value, so the two cannot drift apart. const IMPORT_CHUNK_SIZE: usize = 500; +/// Core user namespace exported when `--user-id` is not given. +const DEFAULT_EXPORT_USER_ID: &str = "default"; use am_cloud_types::{ ExportManifest, ExportMemoryRecord, ExportMemoryScope, IMPORT_SCHEMA_VERSION, @@ -19,8 +21,8 @@ use clap::Subcommand; use crate::cli::GlobalOptions; use crate::commands::client::{ - dashboard_client, dashboard_client_for_export, emit_cloud_export_warning_if_needed, - memory_client_for_profile, + ScopeIdentity, dashboard_client, dashboard_client_for_export, + emit_cloud_export_warning_if_needed, memory_client_for_profile, }; use crate::config::{ resolve_profile, resolve_profile_with_export_identity, store_local_export_project_id, @@ -38,9 +40,12 @@ pub enum MigrateCommand { /// Output file path #[arg(long)] out: Option, - /// Core user namespace (default: default) - #[arg(long, default_value = "default")] - user_id: String, + /// Core user namespace to export [default: your Connected Local identity + /// when a session is linked, otherwise `default`]. Under a Cloud-minted + /// JWT a different namespace is rejected; under a Core API key any + /// namespace can be exported. + #[arg(long)] + user_id: Option, }, /// Import JSONL memories into a cloud project Import { @@ -78,7 +83,7 @@ pub async fn run(cmd: MigrateCommand, global: &GlobalOptions) -> Result<()> { project, out, user_id, - } => run_export(global, &project, out.as_deref(), &user_id).await, + } => run_export(global, &project, out.as_deref(), user_id.as_deref()).await, MigrateCommand::Import { file, target_project, @@ -91,7 +96,7 @@ async fn run_export( global: &GlobalOptions, project_ref: &str, out: Option<&Path>, - user_id: &str, + user_id: Option<&str>, ) -> Result<()> { // Resolve exactly once, before the first await, and build every client // from that one profile. Each additional resolution reopens config.toml, @@ -152,7 +157,12 @@ async fn run_export( pinned_profile.name ); } - let client = memory_client_for_profile(&client_profile).await?; + let (client, identity) = memory_client_for_profile(&client_profile).await?; + // Omitted --user-id follows the Connected Local identity. An explicit value + // is honored under a Core key but must match under a Cloud-minted JWT, + // where Core rejects any other `user_id` with 403. + let user_id = bind_export_user_id(user_id, identity.as_ref())?; + let user_id = user_id.as_str(); client.health().await.map_err(|e| { with_operation_recovery( e.into(), @@ -410,6 +420,97 @@ async fn resolve_project( .ok_or_else(|| anyhow::anyhow!("project not found: {id_or_slug}")) } +/// Resolve the export namespace from `--user-id` and the Connected Local identity. +/// +/// `requested` is `None` when `--user-id` was omitted, so an explicit +/// `--user-id default` stays distinguishable from the default. Omitted → +/// the identity, or `default` without a session. Explicit → honored under a +/// Core key (Core applies no user binding) and when it matches the identity; +/// under a Cloud-minted JWT a different namespace fails before any Core read. +fn bind_export_user_id( + requested: Option<&str>, + identity: Option<&ScopeIdentity>, +) -> Result { + let requested = requested.map(str::trim).filter(|r| !r.is_empty()); + let Some(identity) = identity else { + return Ok(requested.unwrap_or(DEFAULT_EXPORT_USER_ID).to_string()); + }; + match requested { + None => Ok(identity.user.clone()), + Some(requested) if requested == identity.user || !identity.enforced => { + Ok(requested.to_string()) + } + Some(requested) => bail!( + "--user-id '{requested}' conflicts with the Connected Local JWT identity '{user}'.\n\ + A Cloud-minted JWT can only read that identity's memories. Omit --user-id, or \ + export with a Core API key (CORE_API_KEY) to reach another namespace.", + user = identity.user + ), + } +} + +#[cfg(test)] +mod export_user_binding_tests { + use super::bind_export_user_id; + use crate::commands::client::ScopeIdentity; + + fn identity(enforced: bool) -> ScopeIdentity { + ScopeIdentity { + user: "user_member".into(), + enforced, + } + } + + #[test] + fn without_session_exports_requested_or_default() { + assert_eq!(bind_export_user_id(Some("team-a"), None).unwrap(), "team-a"); + assert_eq!(bind_export_user_id(None, None).unwrap(), "default"); + assert_eq!(bind_export_user_id(Some(" "), None).unwrap(), "default"); + } + + #[test] + fn omitted_user_id_follows_identity_on_both_paths() { + for enforced in [true, false] { + assert_eq!( + bind_export_user_id(None, Some(&identity(enforced))).unwrap(), + "user_member" + ); + } + } + + #[test] + fn managed_key_honors_explicit_default() { + // Explicit `default` is distinguishable from omitted and reaches the + // pre-binding namespace under a Core key. + assert_eq!( + bind_export_user_id(Some("default"), Some(&identity(false))).unwrap(), + "default" + ); + assert_eq!( + bind_export_user_id(Some(" team-a "), Some(&identity(false))).unwrap(), + "team-a" + ); + } + + #[test] + fn jwt_accepts_matching_and_rejects_conflicting_namespace() { + assert_eq!( + bind_export_user_id(Some(" user_member "), Some(&identity(true))).unwrap(), + "user_member" + ); + for conflicting in ["other", "default"] { + let err = bind_export_user_id(Some(conflicting), Some(&identity(true))) + .expect_err("conflicting namespace must fail closed under a JWT") + .to_string(); + assert!( + err.contains("conflicts with the Connected Local JWT identity"), + "{err}" + ); + assert!(err.contains("CORE_API_KEY"), "{err}"); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cli/src/hooks/run.rs b/crates/cli/src/hooks/run.rs index 8b37068..aed03a0 100644 --- a/crates/cli/src/hooks/run.rs +++ b/crates/cli/src/hooks/run.rs @@ -9,7 +9,7 @@ use std::io::{self, Read}; use am_core_types::CoreIngestRequest; use crate::cli::GlobalOptions; -use crate::commands::client::memory_client; +use crate::commands::memory::memory_client_with_scope; use crate::commands::memory::scope::{MemoryScope, NamespaceSupport, resolve_memory_scope_with}; use crate::hooks::sanitize::{ clean_compact_summary_text, clean_summary_text, format_additional_context, redact_secrets, @@ -84,7 +84,7 @@ async fn run_user_prompt_submit( return Ok(skip("prompt_too_short")); } let scope = resolve_memory_scope_with(global, None, None, None, NamespaceSupport::Supported)?; - let (_profile, client) = memory_client(global).await?; + let (_profile, client, scope) = memory_client_with_scope(global, scope).await?; let req = am_core_types::CoreSearchRequest { user_id: scope.user_id, query: prompt, @@ -215,6 +215,7 @@ async fn ingest_hook_record( ) -> Result<()> { // `CoreIngestRequest` carries no namespace field. let scope = resolve_memory_scope_with(global, None, None, None, NamespaceSupport::Unsupported)?; + let (_profile, client, scope) = memory_client_with_scope(global, scope).await?; let dedupe_key = hook_dedupe_key(host, event, &scope, content); let metadata = serde_json::json!({ "source": host.id(), @@ -242,7 +243,6 @@ async fn ingest_hook_record( visibility: None, config_override: None, }; - let (_profile, client) = memory_client(global).await?; client.ingest_quick(&req).await?; Ok(()) } diff --git a/crates/cli/src/verification/smoke.rs b/crates/cli/src/verification/smoke.rs index f6796a7..f38ec86 100644 --- a/crates/cli/src/verification/smoke.rs +++ b/crates/cli/src/verification/smoke.rs @@ -93,7 +93,7 @@ pub async fn run_memory_smoke( let profile = resolve_ctx(global) .await .context("resolve profile for smoke test")?; - let client = memory_client_for_profile(&profile).await.map_err(|err| { + let (client, identity) = memory_client_for_profile(&profile).await.map_err(|err| { smoke_recovery( err, "Memory smoke client", @@ -102,7 +102,13 @@ pub async fn run_memory_smoke( opts.mode, ) })?; - run_memory_smoke_with_client(client, opts, telemetry) + // Connected Local identity (Clerk `sub`) must bind into the smoke namespace + // whenever a session is present — including managed CORE_API_KEY HTTP auth. + // Key-only paths without a session keep the dedicated smoke namespace. + let smoke_user = identity + .as_ref() + .map_or(SMOKE_USER_ID, |identity| identity.user.as_str()); + run_memory_smoke_with_client(client, opts, telemetry, smoke_user) .await .map_err(|err| smoke_recovery(err, "Memory smoke", profile.kind, &profile.name, opts.mode)) } @@ -127,6 +133,7 @@ async fn run_memory_smoke_with_client( client: MemoryClient, opts: SmokeOptions, telemetry: Option, + user_id: &str, ) -> Result { let deadline = Instant::now() + opts.timeout; // Reserve cleanup time inside the single overall budget so failed or timed-out @@ -134,7 +141,7 @@ async fn run_memory_smoke_with_client( let pipeline_deadline = deadline - CLEANUP_RESERVE.min(opts.timeout / 4); let client = client.with_timeout(opts.timeout)?; let marker = format!("am-cli-smoke-{}", uuid::Uuid::now_v7()); - let ingest_req = smoke_ingest_request(&marker, opts.mode); + let ingest_req = smoke_ingest_request(&marker, opts.mode, user_id); let ingest = tokio::time::timeout_at(pipeline_deadline, async { match opts.mode { SmokeMode::Quick => client.ingest_quick(&ingest_req).await, @@ -172,13 +179,13 @@ async fn run_memory_smoke_with_client( } else { tokio::time::timeout_at( pipeline_deadline, - retrieve_marker(&client, &marker, &memory_ids, opts.mode), + retrieve_marker(&client, &marker, &memory_ids, opts.mode, user_id), ) .await .map_err(|_| anyhow::anyhow!("smoke search timed out")) .and_then(|result| result) }; - let cleanup = cleanup_memories(&client, &memory_ids, deadline).await; + let cleanup = cleanup_memories(&client, &memory_ids, deadline, user_id).await; let cleaned = match (retrieval, cleanup) { (Ok(()), Ok(cleaned)) => cleaned, (Err(retrieval), Ok(_)) => return Err(retrieval), @@ -200,9 +207,10 @@ async fn retrieve_marker( marker: &str, memory_ids: &[String], mode: SmokeMode, + user_id: &str, ) -> Result<()> { let search_req = CoreSearchRequest { - user_id: SMOKE_USER_ID.into(), + user_id: user_id.into(), query: marker.into(), limit: Some(5), threshold: None, @@ -240,9 +248,10 @@ async fn cleanup_memories( client: &MemoryClient, memory_ids: &[String], deadline: Instant, + user_id: &str, ) -> Result> { let query = CoreMemoryQuery { - user_id: SMOKE_USER_ID.into(), + user_id: user_id.into(), workspace_id: None, agent_id: None, }; @@ -250,7 +259,9 @@ async fn cleanup_memories( let mut failures = Vec::new(); for id in memory_ids { match tokio::time::timeout_at(deadline, client.delete_memory(id, &query)).await { - Ok(Ok(result)) if result.deleted => cleaned.push(id.clone()), + // Core returns `{ "success": true }` (SuccessResponseSchema). Older + // fixtures/proxies may send `{ "deleted": true }`. Either confirms. + Ok(Ok(result)) if result.confirmed() => cleaned.push(id.clone()), Ok(Ok(_)) => failures.push(format!("{id}: deletion was not confirmed")), Ok(Err(err)) => failures.push(format!("{id}: {err}")), Err(_) => failures.push(format!("{id}: cleanup timed out")), @@ -265,10 +276,10 @@ async fn cleanup_memories( Ok(cleaned) } -fn smoke_ingest_request(marker: &str, mode: SmokeMode) -> CoreIngestRequest { +fn smoke_ingest_request(marker: &str, mode: SmokeMode, user_id: &str) -> CoreIngestRequest { let quick = mode == SmokeMode::Quick; CoreIngestRequest { - user_id: SMOKE_USER_ID.into(), + user_id: user_id.into(), source_site: SMOKE_SOURCE_SITE.into(), conversation: if quick { format!("CLI onboarding smoke marker: {marker}") @@ -300,7 +311,7 @@ mod tests { #[test] fn smoke_ingest_request_stamps_verbatim_content_class() { - let req = smoke_ingest_request("marker-abc", SmokeMode::Quick); + let req = smoke_ingest_request("marker-abc", SmokeMode::Quick, SMOKE_USER_ID); assert_eq!(req.skip_extraction, Some(true)); assert_eq!(req.content_class.as_deref(), Some("summary")); assert!(req.conversation.contains("marker-abc")); diff --git a/crates/cli/src/verification/smoke_http_tests.rs b/crates/cli/src/verification/smoke_http_tests.rs index 0083f1a..eff6d27 100644 --- a/crates/cli/src/verification/smoke_http_tests.rs +++ b/crates/cli/src/verification/smoke_http_tests.rs @@ -15,6 +15,9 @@ struct Scenario { requests: Arc>>, marker: Arc>, cleanup_fails: bool, + /// When true, DELETE returns `{ "deleted": true }` instead of Core's + /// `{ "success": true }` so both acknowledgment shapes stay covered. + cleanup_uses_deleted_flag: bool, search_fails: bool, no_facts: bool, duplicate_stored: bool, @@ -48,7 +51,15 @@ async fn handle( if method == Method::DELETE { return ( StatusCode::OK, - Json(json!({"deleted": !scenario.cleanup_fails})), + // Core's SuccessResponseSchema is `{ "success": true }`. The + // `deleted` flag exercises the alternate acknowledgment path. + Json(if scenario.cleanup_fails { + json!({"deleted": false, "success": false}) + } else if scenario.cleanup_uses_deleted_flag { + json!({"deleted": true}) + } else { + json!({"success": true}) + }), ); } if scenario.search_pending { @@ -70,13 +81,17 @@ async fn handle( } async fn run(scenario: Scenario, opts: SmokeOptions) -> Result { + run_as(scenario, opts, SMOKE_USER_ID).await +} + +async fn run_as(scenario: Scenario, opts: SmokeOptions, user_id: &str) -> Result { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let router = Router::new().fallback(any(handle)).with_state(scenario); let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); let client = MemoryClient::new(format!("http://{addr}").parse().unwrap(), "local-admin").unwrap(); - let result = run_memory_smoke_with_client(client, opts, None).await; + let result = run_memory_smoke_with_client(client, opts, None, user_id).await; server.abort(); result } @@ -111,6 +126,28 @@ async fn cleanup_rejection_fails_verification() { ); } +#[tokio::test] +async fn cleanup_accepts_core_success_response_shape() { + // Regression: Core returns `{ "success": true }` while the CLI type only + // looked at `deleted`, so Connected Local smoke always reported + // "deletion was not confirmed" after a successful DELETE. + let scenario = Scenario::default(); + let result = run(scenario, SmokeOptions::default()).await.unwrap(); + assert!(result.verified); + assert_eq!(result.memory_ids_cleaned, ["stored", "updated"]); +} + +#[tokio::test] +async fn cleanup_accepts_deleted_true_acknowledgment() { + let scenario = Scenario { + cleanup_uses_deleted_flag: true, + ..Scenario::default() + }; + let result = run(scenario, SmokeOptions::default()).await.unwrap(); + assert!(result.verified); + assert_eq!(result.memory_ids_cleaned, ["stored", "updated"]); +} + #[tokio::test] async fn retrieval_failure_still_cleans_all_ingested_ids() { let scenario = Scenario { @@ -247,3 +284,51 @@ async fn search_timeout_reserves_time_to_clean_known_memories() { 2 ); } + +/// A Connected Local JWT is minted for the Clerk `sub`; Core rejects every +/// request whose `user_id` differs from it. Once the client is JWT-bound, the +/// smoke ingest, search, and cleanup must all carry that identity rather than +/// the dedicated smoke namespace. +#[tokio::test] +async fn jwt_bound_user_replaces_smoke_namespace_on_every_request() { + let scenario = Scenario::default(); + let requests = scenario.requests.clone(); + run_as(scenario, SmokeOptions::default(), "user_member") + .await + .unwrap(); + let requests = requests.lock().unwrap(); + assert!( + requests.len() >= 3, + "expected ingest, search, and delete requests" + ); + for (method, uri, body) in requests.iter() { + let user = if *method == Method::DELETE { + uri.split('?') + .nth(1) + .and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("user_id="))) + .map(str::to_string) + } else { + body["user_id"].as_str().map(str::to_string) + }; + assert_eq!( + user.as_deref(), + Some("user_member"), + "{method} {uri} {body}" + ); + } +} + +#[tokio::test] +async fn key_based_smoke_keeps_dedicated_namespace() { + let scenario = Scenario::default(); + let requests = scenario.requests.clone(); + run(scenario, SmokeOptions::default()).await.unwrap(); + let ingest = requests + .lock() + .unwrap() + .iter() + .find(|(_, uri, _)| uri.contains("ingest")) + .map(|(_, _, body)| body.clone()) + .expect("ingest request"); + assert_eq!(ingest["user_id"], SMOKE_USER_ID); +} diff --git a/crates/cli/tests/local_token_scope.rs b/crates/cli/tests/local_token_scope.rs new file mode 100644 index 0000000..2646403 --- /dev/null +++ b/crates/cli/tests/local_token_scope.rs @@ -0,0 +1,382 @@ +//! Public memory and hook commands must carry the identity used by local JWT minting. + +#![cfg(unix)] + +#[path = "support/local_token.rs"] +mod support; + +use std::path::Path; +use support::{CORE_KEY, Fixture, MEMBER}; + +fn binary() -> &'static Path { + Path::new(env!("CARGO_BIN_EXE_am")) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn every_memory_and_hook_surface_sends_the_minted_user() { + let fixture = Fixture::new().await; + let cases: &[(&[&str], Option<&str>)] = &[ + (&["memory", "ingest", "fixture memory"], None), + ( + &["memory", "ingest", "--mode", "verbatim", "fixture memory"], + None, + ), + (&["memory", "search", "fixture"], None), + (&["memory", "search", "--fast", "fixture"], None), + (&["memory", "list"], None), + (&["memory", "get", "mem_test"], None), + (&["memory", "delete", "mem_test"], None), + (&["memory", "package", "fixture"], None), + ( + &[ + "hooks", + "run", + "user-prompt-submit", + "--host", + "claude-code", + ], + Some(r#"{"prompt":"remember the fixture architecture decisions"}"#), + ), + ( + &["hooks", "run", "post-compact", "--host", "claude-code"], + Some(r#"{"summary":"The project uses a local memory service for context."}"#), + ), + ( + &["hooks", "run", "stop", "--host", "claude-code"], + Some( + r#"{"last_assistant_message":"Implemented the local memory integration and verified that the persisted configuration is reused across subsequent sessions without overwriting unrelated settings. The tests also cover retrieval, identity binding, and cleanup so future changes preserve the same behavior."}"#, + ), + ), + ]; + for (args, input) in cases { + let before = fixture.requests().len(); + let output = fixture.run(binary(), args, *input); + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + let requests = fixture.requests(); + let calls = &requests[before..]; + assert_eq!(calls.len(), 2, "{args:?}: {calls:?}"); + assert_eq!(calls[0].body["memory_user_id"], MEMBER); + assert_eq!(calls[1].user.as_deref(), Some(MEMBER)); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn matching_scope_override_is_normalized_on_the_wire() { + let fixture = Fixture::new().await; + let output = fixture.run( + binary(), + &[ + "--scope-user", + " user_member ", + "memory", + "search", + "fixture", + ], + None, + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fixture.requests().last().unwrap().user.as_deref(), + Some(MEMBER) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conflicting_scope_override_never_reaches_core() { + let fixture = Fixture::new().await; + let output = fixture.run( + binary(), + &[ + "--scope-user", + "another_user", + "memory", + "search", + "fixture", + ], + None, + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("conflicts")); + assert!( + fixture + .requests() + .iter() + .all(|request| request.path == "/v1/local/token") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unbound_or_foreign_sessions_never_disclose_identity() { + for binding in [None, Some("https://other.example")] { + let fixture = Fixture::new().await; + fixture.edit("credentials.toml", |value| { + let oauth = value["oauth"]["cloud"].as_table_mut().unwrap(); + oauth.remove("api_origin"); + if let Some(binding) = binding { + oauth.insert("api_origin".into(), binding.into()); + } + }); + let output = fixture.run(binary(), &["connect", "token", "--print-token"], None); + assert!(!output.status.success()); + assert!( + fixture.requests().is_empty(), + "identity must not leave the process" + ); + let error = String::from_utf8_lossy(&output.stderr); + assert!( + error.contains("binding") || error.contains("acquired for"), + "{error}" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn matching_origin_uses_expired_cached_identity_without_refresh() { + let fixture = Fixture::new().await; + fixture.edit("credentials.toml", |value| { + value["oauth"]["cloud"]["expires_at"] = 0.into(); + }); + let output = fixture.run(binary(), &["connect", "token", "--print-token"], None); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(fixture.requests().len(), 1); + assert_eq!(fixture.requests()[0].path, "/v1/local/token"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn foreign_issuer_is_rejected_even_on_matching_api_origin() { + let fixture = Fixture::new().await; + fixture.edit("credentials.toml", |value| { + value["oauth"]["cloud"]["issuer"] = "https://other-issuer.example".into(); + }); + let output = fixture.run(binary(), &["connect", "token", "--print-token"], None); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("issued by")); + assert!(fixture.requests().is_empty()); +} + +const MANAGED_CORE_KEY: &[(&str, &str)] = &[("CORE_API_KEY", CORE_KEY)]; + +fn stderr(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// The single user-scoped Core request (health carries no `user_id`). +fn core_memory_request(fixture: &Fixture) -> support::Request { + let requests = fixture.requests(); + let core: Vec<_> = requests + .iter() + .filter(|r| r.path.starts_with("/v1/memories") && r.path != "/v1/memories/health") + .cloned() + .collect(); + assert_eq!( + core.len(), + 1, + "expected one user-scoped Core request: {requests:?}" + ); + core.into_iter().next().unwrap() +} + +fn export_args<'a>(out: &'a str, user_id: Option<&'a str>) -> Vec<&'a str> { + let mut args = vec!["migrate", "export", "--project", "proj_test", "--out", out]; + if let Some(user_id) = user_id { + args.extend(["--user-id", user_id]); + } + args +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn managed_core_key_with_session_binds_clerk_identity() { + // UTM-2 default: Connected Local session + persisted/env CORE_API_KEY. + // The session identity is the default namespace even though HTTP auth + // uses the key and never mints. + let fixture = Fixture::new().await; + let output = fixture.run_with_envs( + binary(), + &["memory", "search", "fixture"], + None, + MANAGED_CORE_KEY, + ); + assert!(output.status.success(), "{}", stderr(&output)); + assert!( + fixture + .requests() + .iter() + .all(|r| r.path != "/v1/local/token"), + "managed key must not mint" + ); + let search = core_memory_request(&fixture); + assert_eq!(search.user.as_deref(), Some(MEMBER)); + assert_eq!(search.auth.as_deref(), Some(CORE_KEY)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn managed_core_key_with_session_allows_explicit_scope_user_override() { + // Core applies no user binding under a static key, so an explicit + // --scope-user must reach Core. `default` is the namespace every + // pre-binding install wrote to on this path. + let fixture = Fixture::new().await; + let output = fixture.run_with_envs( + binary(), + &["--scope-user", "default", "memory", "search", "fixture"], + None, + MANAGED_CORE_KEY, + ); + assert!(output.status.success(), "{}", stderr(&output)); + assert!( + !stderr(&output).contains("conflicts"), + "{}", + stderr(&output) + ); + let search = core_memory_request(&fixture); + assert_eq!(search.user.as_deref(), Some("default")); + assert_eq!(search.auth.as_deref(), Some(CORE_KEY)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn managed_core_key_without_session_allows_explicit_scope_user() { + // Pure Core-key admin (no Connected Local session) may target any namespace. + let fixture = Fixture::new().await; + fixture.edit("credentials.toml", |value| { + value.as_table_mut().unwrap().remove("oauth"); + }); + fixture.edit("config.toml", |value| { + value["profiles"]["local"] + .as_table_mut() + .unwrap() + .remove("oauth_ref"); + }); + let output = fixture.run_with_envs( + binary(), + &["--scope-user", "team_a", "memory", "search", "fixture"], + None, + MANAGED_CORE_KEY, + ); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!( + core_memory_request(&fixture).user.as_deref(), + Some("team_a") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unauthorizable_session_with_managed_key_falls_back_unbound_with_warning() { + // A stale or foreign session must not break a Core the key authenticates. + let fixture = Fixture::new().await; + fixture.edit("credentials.toml", |value| { + value["oauth"]["cloud"]["api_origin"] = "https://other.example".into(); + }); + let output = fixture.run_with_envs( + binary(), + &["memory", "search", "fixture"], + None, + MANAGED_CORE_KEY, + ); + assert!(output.status.success(), "{}", stderr(&output)); + let err = stderr(&output); + assert!(err.contains("could not be authorized"), "{err}"); + assert!(err.contains("`am auth login`"), "{err}"); + let search = core_memory_request(&fixture); + assert_eq!(search.user.as_deref(), Some("default"), "unbound fallback"); + assert_eq!(search.auth.as_deref(), Some(CORE_KEY)); + assert!( + fixture + .requests() + .iter() + .all(|r| r.path != "/v1/local/token" && !r.path.contains("oauth")), + "fallback must not mint or touch OAuth: {:?}", + fixture.requests() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn managed_core_key_export_defaults_to_session_identity() { + let fixture = Fixture::new().await; + let out = fixture.home.path().join("export.jsonl"); + let out = out.to_str().unwrap(); + let output = fixture.run_with_envs(binary(), &export_args(out, None), None, MANAGED_CORE_KEY); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!(core_memory_request(&fixture).user.as_deref(), Some(MEMBER)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn managed_core_key_export_honors_explicit_default_user_id() { + // Explicit `--user-id default` is distinguishable from omitted, and under + // a Core key it exports the pre-binding namespace. + let fixture = Fixture::new().await; + let out = fixture.home.path().join("export.jsonl"); + let out = out.to_str().unwrap(); + let output = fixture.run_with_envs( + binary(), + &export_args(out, Some("default")), + None, + MANAGED_CORE_KEY, + ); + assert!(output.status.success(), "{}", stderr(&output)); + let list = core_memory_request(&fixture); + assert_eq!(list.user.as_deref(), Some("default")); + assert_eq!(list.auth.as_deref(), Some(CORE_KEY)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn jwt_export_rejects_explicit_default_user_id() { + // Under a Cloud-minted JWT, Core would 403 any user_id other than the + // minted identity, so an explicit `default` must fail before any Core read. + let fixture = Fixture::new().await; + let out = fixture.home.path().join("export.jsonl"); + let out = out.to_str().unwrap(); + let output = fixture.run(binary(), &export_args(out, Some("default")), None); + assert!( + !output.status.success(), + "explicit default must fail under JWT" + ); + let err = stderr(&output); + assert!( + err.contains("conflicts with the Connected Local JWT identity"), + "{err}" + ); + assert!( + fixture + .requests() + .iter() + .all(|r| !r.path.starts_with("/v1/memories")), + "conflict must not reach Core: {:?}", + fixture.requests() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn health_only_callers_skip_identity_resolution() { + // `am health` sends no user_id, so it must not warn about an unauthorizable + // session or resolve identity it never uses. + let fixture = Fixture::new().await; + fixture.edit("credentials.toml", |value| { + value["oauth"]["cloud"]["api_origin"] = "https://other.example".into(); + }); + let output = fixture.run_with_envs(binary(), &["health"], None, MANAGED_CORE_KEY); + assert!(output.status.success(), "{}", stderr(&output)); + assert!( + !stderr(&output).contains("could not be authorized"), + "{}", + stderr(&output) + ); + assert!( + fixture + .requests() + .iter() + .any(|r| r.path == "/v1/memories/health" && r.auth.as_deref() == Some(CORE_KEY)), + "health must still reach Core with the key: {:?}", + fixture.requests() + ); +} diff --git a/crates/cli/tests/support/local_token.rs b/crates/cli/tests/support/local_token.rs new file mode 100644 index 0000000..0fdf16c --- /dev/null +++ b/crates/cli/tests/support/local_token.rs @@ -0,0 +1,307 @@ +//! Isolated CLI credentials and loopback Cloud/Core contracts for JWT regressions. + +#![allow(dead_code)] + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::{Arc, Mutex}; + +use axum::{ + Json, Router, + body::Bytes, + extract::State, + http::{HeaderMap, Method, StatusCode, Uri}, + routing::any, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde_json::{Value, json}; + +pub const MEMBER: &str = "user_member"; +/// Static Core key. Real Core applies no user binding on this branch of +/// `dual-auth.ts`; only Cloud-minted JWTs are bound to `memory_user_id`. +pub const CORE_KEY: &str = "core_managed_fixture"; +const KEY_NAME: &str = "connected-local-runtime-a1b2c3d4e5f6"; + +#[derive(Clone, Debug)] +pub struct Request { + pub path: String, + pub body: Value, + pub user: Option, + /// Bearer credential the CLI presented. + pub auth: Option, +} + +#[derive(Default)] +pub struct Api { + pub requests: Vec, + pub fail_first_discovery: bool, +} + +pub struct Fixture { + pub home: tempfile::TempDir, + pub config: PathBuf, + pub base: String, + pub api: Arc>, + server: tokio::task::JoinHandle<()>, +} + +impl Fixture { + pub async fn new() -> Self { + let home = tempfile::tempdir().unwrap(); + let config = if cfg!(target_os = "macos") { + home.path() + .join("Library/Application Support/ai.atomicstrata.atomicmemory") + } else { + home.path().join("config/atomicmemory") + }; + fs::create_dir_all(&config).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let api = Arc::new(Mutex::new(Api::default())); + let app = Router::new() + .fallback(any(handle)) + .with_state((api.clone(), base.clone())); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let fixture = Self { + home, + config, + base, + api, + server, + }; + fixture.write_config(); + fixture + } + + fn write_config(&self) { + fs::write( + self.config.join("config.toml"), + format!( + r#" +default_profile = "local" +cli_key_id = "a1b2c3d4e5f6" +[oauth] +issuer = "{base}" +client_id = "fixture_client" +[profiles.local] +kind = "local" +base_url = "{base}" +local_url = "{base}" +project_id = "proj_test" +api_key_ref = "local" +oauth_ref = "cloud" +"#, + base = self.base + ), + ) + .unwrap(); + fs::write( + self.config.join("credentials.toml"), + format!( + r#" +[oauth.cloud] +id_token = "{token}" +refresh_token = "fixture_refresh" +expires_at = 4102444800 +issuer = "{base}" +api_origin = "{base}" +[api_keys.local] +secret = "amc_fixture" +api_origin = "{base}" +project_id = "proj_test" +"#, + token = token(), + base = self.base + ), + ) + .unwrap(); + } + + pub fn edit(&self, file: &str, change: impl FnOnce(&mut toml::Value)) { + let path = self.config.join(file); + let mut value = fs::read_to_string(&path).unwrap().parse().unwrap(); + change(&mut value); + fs::write(path, toml::to_string(&value).unwrap()).unwrap(); + } + + pub fn command(&self, binary: &Path) -> Command { + let mut command = Command::new(binary); + for (name, _) in std::env::vars_os() { + let key = name.to_string_lossy(); + if key.starts_with("ATOMICMEMORY_") + || key.starts_with("CORE_") + || key.starts_with("AM_") + { + command.env_remove(name); + } + } + // No Docker binary: exercise the external Core JWT path deterministically. + command + .env("HOME", self.home.path()) + .env("XDG_CONFIG_HOME", self.home.path().join("config")) + .env("XDG_DATA_HOME", self.home.path().join("data")) + .env("PATH", self.home.path().join("empty-bin")) + .env("AM_TELEMETRY", "0"); + command + } + + pub fn run(&self, binary: &Path, args: &[&str], input: Option<&str>) -> Output { + self.run_with_envs(binary, args, input, &[]) + } + + /// Run with extra env after clearing AtomicMemory/Core overrides. + /// + /// Used to force the managed `CORE_API_KEY` HTTP path while keeping the + /// Connected Local OAuth session on the profile (UTM-2's default). + pub fn run_with_envs( + &self, + binary: &Path, + args: &[&str], + input: Option<&str>, + envs: &[(&str, &str)], + ) -> Output { + let mut command = self.command(binary); + for (key, value) in envs { + command.env(key, value); + } + let mut child = command + .args(["--no-telemetry", "-o", "json"]) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + if let Some(input) = input { + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + } + child.wait_with_output().unwrap() + } + + pub fn requests(&self) -> Vec { + self.api.lock().unwrap().requests.clone() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} + +fn token() -> String { + let payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"user_member","exp":4102444800}"#); + format!("hdr.{payload}.sig") +} + +async fn handle( + State((api, base)): State<(Arc>, String)>, + method: Method, + uri: Uri, + headers: HeaderMap, + bytes: Bytes, +) -> (StatusCode, Json) { + let auth = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::to_string); + let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + let user = body + .get("user_id") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + url::form_urlencoded::parse(uri.query().unwrap_or_default().as_bytes()) + .find(|(key, _)| key == "user_id") + .map(|(_, value)| value.into_owned()) + }); + let path = uri.path(); + let mut api = api.lock().unwrap(); + api.requests.push(Request { + path: path.into(), + body: body.clone(), + user: user.clone(), + auth: auth.clone(), + }); + let reply = match path { + "/.well-known/oauth-authorization-server" => { + if api.fail_first_discovery { + api.fail_first_discovery = false; + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error":"temporary discovery failure"})), + ); + } + json!({"authorization_endpoint":format!("{base}/authorize"), "token_endpoint":format!("{base}/oauth/token")}) + } + "/oauth/token" => { + json!({"id_token":token(), "refresh_token":"fixture_refresh", "expires_in":3600}) + } + "/v1/local/token" => { + if body != json!({"memory_user_id":MEMBER}) { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"error":"wrong mint identity"})), + ); + } + json!({"access_token":token(), "token_type":"Bearer", "expires_in":300}) + } + "/api/projects/proj_test" => json!({ + "id":"proj_test", + "org_id":"org_test", + "name":"Fixture Local", + "slug":"fixture-local", + "environment":"production", + "type":"local", + "local_url": base, + "privacy_mode":"connect", + "created_at":"2026-01-01T00:00:00Z" + }), + "/api/projects/proj_test/api-keys" => json!([key()]), + "/api/projects/proj_test/api-keys/key_existing/rotate" => { + let mut key = key(); + key["secret"] = "amc_rotated_fixture".into(); + key + } + "/v1/memories/health" => json!({"status":"ok"}), + _ if path.starts_with("/v1/memories") => { + // Core compares the wire user_id literally with a JWT's + // memory_user_id; the static key branch applies no binding. + if auth.as_deref() != Some(CORE_KEY) && user.as_deref() != Some(MEMBER) { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error":{"code":"user_binding_mismatch"}})), + ); + } + match (method, path) { + (Method::DELETE, _) => json!({"success":true}), + (_, "/v1/memories/ingest" | "/v1/memories/ingest/quick") => { + json!({"episode_id":"ep_test", "memories_stored":1}) + } + (_, "/v1/memories/mem_test") => { + json!({"id":"mem_test", "content":"fixture memory"}) + } + _ => json!({"memories":[], "count":0}), + } + } + _ => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error":"unexpected request"})), + ); + } + }; + (StatusCode::OK, Json(reply)) +} + +fn key() -> Value { + json!({"id":"key_existing", "project_id":"proj_test", "name":KEY_NAME, "prefix":"amc_", "status":"active", "created_at":"2026-01-01T00:00:00Z", "last_used_at":null}) +} diff --git a/crates/cloud-client/src/client.rs b/crates/cloud-client/src/client.rs index a1f8e18..de8b608 100644 --- a/crates/cloud-client/src/client.rs +++ b/crates/cloud-client/src/client.rs @@ -2,8 +2,8 @@ use am_cloud_types::{ ApiKey, ApiKeyWithSecret, CreateApiKeyRequest, CreateOrgRequest, CreateProjectRequest, - EnsureOnboardingRequest, EnsureOnboardingResponse, LocalCoreTokenResponse, Memory, - MemoryWithEvidence, OnboardingStatusResponse, Organization, Project, RuntimeSummary, + EnsureOnboardingRequest, EnsureOnboardingResponse, LocalCoreTokenResponse, LocalTokenRequest, + Memory, MemoryWithEvidence, OnboardingStatusResponse, Organization, Project, RuntimeSummary, TraceDetail, TraceSummary, UpdateProjectRequest, UsageSummary, }; use am_core_types::{ @@ -335,8 +335,17 @@ impl MemoryClient { } /// Mint a short-lived JWT for headless access to a connected-local Core (`POST /v1/local/token`). - pub async fn mint_local_token(&self) -> Result { - self.transport.post("v1/local/token", &EmptyBody).await + /// + /// Always posts a JSON object (`LocalTokenRequest`). Never send a unit/`()`/ + /// `Option::None` body — those serialize as JSON `null` and the Cloud API + /// rejects them with 422 (`expected struct LocalTokenRequest`). + pub async fn mint_local_token( + &self, + req: &LocalTokenRequest, + ) -> Result { + req.validate() + .map_err(|e| CloudClientError::Validation(e.to_string()))?; + self.transport.post("v1/local/token", req).await } } diff --git a/crates/cloud-client/tests/client.rs b/crates/cloud-client/tests/client.rs index 7c6401e..a63cbb2 100644 --- a/crates/cloud-client/tests/client.rs +++ b/crates/cloud-client/tests/client.rs @@ -1,9 +1,10 @@ //! Integration tests for the cloud HTTP client (wiremock). use am_cloud_client::{DashboardClient, MemoryClient}; +use am_cloud_types::LocalTokenRequest; use am_core_types::{CoreIngestRequest, CoreSearchRequest}; use url::Url; -use wiremock::matchers::{bearer_token, method, path}; +use wiremock::matchers::{bearer_token, body_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test] @@ -100,12 +101,21 @@ async fn dashboard_list_orgs() { assert_eq!(orgs[0].slug, "acme"); } +fn mint_request() -> LocalTokenRequest { + LocalTokenRequest { + memory_user_id: "user_clerk_abc".into(), + } +} + #[tokio::test] async fn memory_mint_local_token() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/local/token")) .and(bearer_token("amc_test_key")) + .and(body_json(serde_json::json!({ + "memory_user_id": "user_clerk_abc" + }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "access_token": "eyJ.test", "token_type": "Bearer", @@ -115,11 +125,36 @@ async fn memory_mint_local_token() { .await; let client = MemoryClient::new(Url::parse(&server.uri()).unwrap(), "amc_test_key").unwrap(); - let resp = client.mint_local_token().await.unwrap(); + let resp = client.mint_local_token(&mint_request()).await.unwrap(); assert_eq!(resp.access_token, "eyJ.test"); assert_eq!(resp.expires_in, 300); } +/// Regression: a null JSON body is what caused Cloud to answer +/// `invalid type: null, expected struct LocalTokenRequest`. The client must +/// post an object; wiremock only matches when the body equals the object below. +#[tokio::test] +async fn mint_local_token_rejects_null_shaped_bodies_by_requiring_object_match() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/local/token")) + .and(body_json(serde_json::json!({ + "memory_user_id": "user_clerk_abc" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "eyJ.ok", + "token_type": "Bearer", + "expires_in": 60 + }))) + .expect(1) + .mount(&server) + .await; + + let client = MemoryClient::new(Url::parse(&server.uri()).unwrap(), "amc_test_key").unwrap(); + let resp = client.mint_local_token(&mint_request()).await.unwrap(); + assert_eq!(resp.access_token, "eyJ.ok"); +} + #[tokio::test] async fn dashboard_list_runtimes() { let server = MockServer::start().await; @@ -227,7 +262,7 @@ async fn mint_preserves_non_json_failure_context() { .mount(&server) .await; let client = MemoryClient::new(Url::parse(&server.uri()).unwrap(), "amc_test_key").unwrap(); - let error = client.mint_local_token().await.unwrap_err(); + let error = client.mint_local_token(&mint_request()).await.unwrap_err(); let display = error.to_string(); for context in ["422", "POST", "v1/local/token", "expected an object"] { assert!(display.contains(context), "missing {context}: {display}"); @@ -247,7 +282,11 @@ async fn mint_bounds_and_redacts_non_json_failure() { .mount(&server) .await; let client = MemoryClient::new(Url::parse(&server.uri()).unwrap(), "amc_test_key").unwrap(); - let display = client.mint_local_token().await.unwrap_err().to_string(); + let display = client + .mint_local_token(&mint_request()) + .await + .unwrap_err() + .to_string(); assert!(display.contains("upstream failure")); assert!(!display.contains("jwt-secret")); assert!(!display.contains("key-secret")); @@ -268,7 +307,11 @@ async fn mint_redacts_raw_credential_echo_and_json_secret_fields() { .await; let client = MemoryClient::new(Url::parse(&server.uri()).unwrap(), "opaque-credential").unwrap(); - let display = client.mint_local_token().await.unwrap_err().to_string(); + let display = client + .mint_local_token(&mint_request()) + .await + .unwrap_err() + .to_string(); assert!(display.contains("validation_error")); for private in [ "opaque-credential", @@ -278,3 +321,22 @@ async fn mint_redacts_raw_credential_echo_and_json_secret_fields() { assert!(!display.contains(private), "leaked {private}: {display}"); } } + +#[tokio::test] +async fn mint_local_token_rejects_empty_memory_user_id_before_http() { + let client = MemoryClient::new( + Url::parse("https://api.example.com").unwrap(), + "amc_test_key", + ) + .unwrap(); + let err = client + .mint_local_token(&LocalTokenRequest { + memory_user_id: String::new(), + }) + .await + .unwrap_err(); + assert!(matches!( + err, + am_cloud_client::CloudClientError::Validation(_) + )); +} diff --git a/crates/cloud-types/src/local_token.rs b/crates/cloud-types/src/local_token.rs index 66770bc..7834c52 100644 --- a/crates/cloud-types/src/local_token.rs +++ b/crates/cloud-types/src/local_token.rs @@ -1,7 +1,20 @@ -//! Wire types for local-core JWT mint responses. +//! Wire types for local-core JWT mint request/response. use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use validator::Validate; + +/// Body for `POST /v1/local/token`. +/// +/// Must serialize as a JSON **object**. A unit/`()`/`Option::None` body becomes +/// JSON `null`, which the Cloud API rejects with 422 +/// (`expected struct LocalTokenRequest`). +#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)] +pub struct LocalTokenRequest { + /// Clerk user id (`sub`) of the project member bound into the Core JWT. + #[validate(length(min = 1, max = 256))] + pub memory_user_id: String, +} #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LocalCoreTokenResponse { @@ -19,3 +32,37 @@ impl LocalCoreTokenResponse { } } } + +#[cfg(test)] +mod tests { + use super::*; + use validator::Validate; + + #[test] + fn local_token_request_serializes_as_object_never_null() { + let req = LocalTokenRequest { + memory_user_id: "user_clerk_abc".into(), + }; + let value = serde_json::to_value(&req).expect("serialize"); + assert!(value.is_object(), "must be a JSON object, got {value}"); + assert_eq!(value["memory_user_id"], "user_clerk_abc"); + assert!(!value.is_null()); + } + + #[test] + fn local_token_request_rejects_empty_memory_user_id() { + let req = LocalTokenRequest { + memory_user_id: String::new(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn unit_struct_documents_null_body_regression() { + // The pre-fix mint path posted a unit struct; serde_json emits null. + #[derive(Serialize)] + struct EmptyBody; + let value = serde_json::to_value(&EmptyBody).expect("serialize"); + assert!(value.is_null()); + } +} diff --git a/crates/core-types/src/lib.rs b/crates/core-types/src/lib.rs index 27f8c86..f939bd0 100644 --- a/crates/core-types/src/lib.rs +++ b/crates/core-types/src/lib.rs @@ -255,12 +255,23 @@ pub struct CoreListMemoriesResponse { #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct CoreDeleteMemoryResponse { + /// Legacy / alternate acknowledgment used by some fixtures and proxies. #[serde(default)] pub deleted: bool, + /// Core's documented SuccessResponseSchema shape (`{ "success": true }`). + #[serde(default)] + pub success: bool, #[serde(default)] pub id: Option, } +impl CoreDeleteMemoryResponse { + /// True when Core acknowledged the delete (`deleted` or `success`). + pub fn confirmed(&self) -> bool { + self.deleted || self.success + } +} + // --------------------------------------------------------------------------- // Documents // --------------------------------------------------------------------------- @@ -887,6 +898,22 @@ mod tests { assert_eq!(parsed.source_distribution.get("manual"), Some(&8.0)); } + #[test] + fn core_delete_memory_confirms_success_or_deleted() { + let core_shape: CoreDeleteMemoryResponse = + serde_json::from_value(serde_json::json!({"success": true})).unwrap(); + assert!(core_shape.confirmed()); + assert!(!core_shape.deleted); + + let legacy: CoreDeleteMemoryResponse = + serde_json::from_value(serde_json::json!({"deleted": true})).unwrap(); + assert!(legacy.confirmed()); + + let neither: CoreDeleteMemoryResponse = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(!neither.confirmed()); + } + #[test] fn core_register_document_request_roundtrips() { let request = CoreRegisterDocumentRequest {