diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index fcf7e8f698c..bd370ded17f 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -132,8 +132,8 @@ jobs: # and leave a stale digest that only fails after the download. Both values # are the ubuntu-22.04-x86_64 row of `crates/openhuman-core/src/modules/registry.rs`, # which is the authoritative pin — copy them from there, never recompute. - memory_version="1.21.1" - memory_sha256="f208b1bf05e52af4ef7a9902d656827c471aa0a50aa3015ddb0ef9058e524c53" + memory_version="1.22.2" + memory_sha256="89f1da9f53e939263f3a7d9f4c6c19b52951bde308a9beb6efedc2b6c272163e" module_root="/opt/openhuman-test-modules/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" memory_dir="$module_root/tinymemory" juice_dir="$module_root/tinyjuice" @@ -333,8 +333,8 @@ jobs: # tinybus validates every directory ancestor. GitHub mounts the # checkout as the host runner uid while this container runs as root, # so keep native fixtures in a root-owned, ephemeral tree. - memory_version="1.21.1" - memory_sha256="f208b1bf05e52af4ef7a9902d656827c471aa0a50aa3015ddb0ef9058e524c53" + memory_version="1.22.2" + memory_sha256="89f1da9f53e939263f3a7d9f4c6c19b52951bde308a9beb6efedc2b6c272163e" module_root="/opt/openhuman-test-modules/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" memory_dir="$module_root/tinymemory" juice_dir="$module_root/tinyjuice" diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml index 2e50b1fc85b..4d9ef9fddde 100644 --- a/.github/workflows/e2e-playwright.yml +++ b/.github/workflows/e2e-playwright.yml @@ -68,8 +68,8 @@ jobs: # tinybus validates every directory ancestor. GitHub mounts the # checkout as the host runner uid while this container runs as root, # so keep native fixtures in a root-owned, ephemeral tree. - memory_version="1.21.1" - memory_sha256="f208b1bf05e52af4ef7a9902d656827c471aa0a50aa3015ddb0ef9058e524c53" + memory_version="1.22.2" + memory_sha256="89f1da9f53e939263f3a7d9f4c6c19b52951bde308a9beb6efedc2b6c272163e" module_root="/opt/openhuman-test-modules/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" memory_dir="$module_root/tinymemory" juice_dir="$module_root/tinyjuice" diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 9c517a7cf0b..93053a5a2b2 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -168,8 +168,8 @@ jobs: # and leave a stale digest that only fails after the download. Both values # are the ubuntu-22.04-x86_64 row of `crates/openhuman-core/src/modules/registry.rs`, # which is the authoritative pin — copy them from there, never recompute. - memory_version="1.21.1" - memory_sha256="f208b1bf05e52af4ef7a9902d656827c471aa0a50aa3015ddb0ef9058e524c53" + memory_version="1.22.2" + memory_sha256="89f1da9f53e939263f3a7d9f4c6c19b52951bde308a9beb6efedc2b6c272163e" module_root="/opt/openhuman-test-modules/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" memory_dir="$module_root/tinymemory" memory_archive="$memory_dir/tinymemory-module-${memory_version}-ubuntu-22.04-x86_64.tar.gz" @@ -373,8 +373,8 @@ jobs: # and leave a stale digest that only fails after the download. Both values # are the ubuntu-22.04-x86_64 row of `crates/openhuman-core/src/modules/registry.rs`, # which is the authoritative pin — copy them from there, never recompute. - memory_version="1.21.1" - memory_sha256="f208b1bf05e52af4ef7a9902d656827c471aa0a50aa3015ddb0ef9058e524c53" + memory_version="1.22.2" + memory_sha256="89f1da9f53e939263f3a7d9f4c6c19b52951bde308a9beb6efedc2b6c272163e" module_root="/opt/openhuman-test-modules/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" memory_dir="$module_root/tinymemory" connectors_dir="$module_root/tinyconnectors" @@ -487,8 +487,8 @@ jobs: # fixture outside the checkout owned by the host runner. # Version and digest are the ubuntu-22.04-x86_64 registry pin; do # not derive a replacement checksum from a local build. - memory_version="1.21.1" - memory_sha256="f208b1bf05e52af4ef7a9902d656827c471aa0a50aa3015ddb0ef9058e524c53" + memory_version="1.22.2" + memory_sha256="89f1da9f53e939263f3a7d9f4c6c19b52951bde308a9beb6efedc2b6c272163e" juice_version="0.5.0" juice_sha256="94c8998c830d9454560c00055187198a598eefaf5be51bcecdcd194a66f3b388" module_root="/opt/openhuman-test-modules/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" diff --git a/.gitignore b/.gitignore index 462e0ffb437..c69138bf88f 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,6 @@ diff-coverage.md # Local/self-hosted CI run output (never commit) ci-out/ ci-out-ex63/ + +# Cargo build output inside the in-tree motosan-ai-oauth crate +vendor/motosan-ai-oauth/target/ diff --git a/AGENTS.md b/AGENTS.md index a70864f89a7..22dc331bf46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,17 @@ coverage must be at least 80 percent. - Frontend unit tests are colocated as `*.test.ts` or `*.test.tsx` under `app/src/`. Use Vitest and test behavior rather than implementation. +- Rust unit tests are never inline. Put them in a sibling `_tests.rs` + (`mod_tests.rs` beside a `mod.rs`) declared at the bottom of the module with + `#[cfg(test)]` and `#[path = "_tests.rs"]` above `mod tests;`. The file + starts with `use super::*;` and carries no `#[cfg(test)]` of its own. Never name + one `test.rs`, `tests.rs` or `_test.rs`, and never write an inline + `#[cfg(test)] mod tests { ... }` (`pnpm rust:layout` fails on an inline module + and on `test.rs`/`tests.rs`; it does not yet catch `_test.rs`). The same + rule binds every `vendor/` submodule: `node scripts/externalize-inline-tests.mjs + --write` converts one mechanically, and a crate root directly in + `src/bin/` keeps its tests in `src/bin//` because Cargo builds any `.rs` + placed straight in `src/bin/` as a binary. - Rust domain tests live beside their modules. Use `scripts/test-rust-with-mock.sh` for tests that need the shared mock backend. - JSON-RPC behavior belongs in Rust E2E tests, commonly diff --git a/Cargo.lock b/Cargo.lock index 06f8e3b0744..811c4c114dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,7 +1243,7 @@ dependencies = [ "core-foundation-sys 0.8.7", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2 0.4.3", @@ -3039,6 +3039,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -3970,7 +4000,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni", + "jni 0.21.1", "ndk", "ndk-context", "num-derive", @@ -5375,6 +5405,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -5385,6 +5427,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -5876,6 +5945,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -5972,6 +6051,17 @@ dependencies = [ "socketioxide-core", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spki" version = "0.7.3" @@ -6653,7 +6743,7 @@ dependencies = [ [[package]] name = "tinyconnectors" -version = "0.11.0" +version = "0.12.1" dependencies = [ "async-trait", "chrono", @@ -6672,7 +6762,7 @@ dependencies = [ [[package]] name = "tinyconnectors-bus" -version = "0.11.0" +version = "0.12.1" dependencies = [ "serde", "serde_json", @@ -6680,7 +6770,7 @@ dependencies = [ [[package]] name = "tinyconnectors-sync" -version = "0.11.0" +version = "0.12.1" dependencies = [ "async-trait", "chrono", @@ -7010,7 +7100,7 @@ dependencies = [ [[package]] name = "tinymemory" -version = "1.21.1" +version = "1.22.2" dependencies = [ "anyhow", "async-trait", @@ -7845,8 +7935,10 @@ dependencies = [ "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", + "socks", "ureq-proto", "utf8-zero", "webpki-roots 1.0.9", @@ -8243,6 +8335,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/app/scripts/e2e-web-session.sh b/app/scripts/e2e-web-session.sh index a0ef942b464..dd511d4b1c2 100755 --- a/app/scripts/e2e-web-session.sh +++ b/app/scripts/e2e-web-session.sh @@ -239,6 +239,41 @@ if [ "$BUILT_CORE_PORT" != "$OPENHUMAN_CORE_PORT" ]; then exit 1 fi +# Refuse a bundle older than the sources it was built from (#5919). +# +# The session serves the prebuilt `dist-web`, so editing `app/src` and re-running +# changes nothing in the browser: the spec runs against the PREVIOUS bundle and +# passes, which makes a revert-proof or a fault injection prove nothing at all. +# The marker is written after `build:web` returns, so anything newer than it is a +# source change this bundle does not contain. `-quit` stops at the first hit, so +# this is a stat walk rather than a hash of the tree, and directories are scanned +# too: a file added, removed or renamed after the build shows up as a newer +# directory even when no surviving file is newer. +# +# A missing input is an error rather than a skipped check. `find` on a path that +# does not exist reports nothing, so a renamed input would silently turn this +# gate off and leave the stale bundle served again. +STALE_SOURCE="" +for bundle_input in src public index.html vite.config.ts; do + input_path="$APP_DIR/$bundle_input" + if [ ! -e "$input_path" ]; then + echo "ERROR: bundle input $input_path does not exist, so staleness cannot be checked." >&2 + echo " Update the input list in $(basename "$0") to match the current layout." >&2 + exit 1 + fi + if [ -z "$STALE_SOURCE" ]; then + STALE_SOURCE="$(find "$input_path" -newer "$E2E_BUNDLE_MARKER" -print -quit)" + fi +done +if [ -n "$STALE_SOURCE" ]; then + echo "ERROR: $APP_DIR/dist-web is older than the sources it was built from." >&2 + echo " Newer than the bundle: $STALE_SOURCE" >&2 + echo " The bundle does not carry that change, so the specs would run against" >&2 + echo " the previous one and pass." >&2 + echo " Rebuild first: pnpm --filter openhuman-app test:e2e:web:build" >&2 + exit 1 +fi + export OPENHUMAN_CORE_BIN="$E2E_WEB_CORE_TARGET_DIR/debug/openhuman-core" if [ ! -x "$OPENHUMAN_CORE_BIN" ]; then echo "ERROR: standalone core binary is missing at $OPENHUMAN_CORE_BIN. Run pnpm --filter openhuman-app test:e2e:web:build first." >&2 diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index 01e9fbe06c4..4cbdd4af529 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -24,6 +24,7 @@ import SettingsTabbedPage from '../components/settings/layout/SettingsTabbedPage import MemoryEngineErrorAlert from '../components/settings/panels/MemoryEngineErrorAlert'; import { classifyMemoryEngineError } from '../components/settings/panels/memoryEngineUtils'; import { Alert, AlertDescription, Card } from '../components/ui'; +import { CenteredLoadingState } from '../components/ui/LoadingState'; import { useT } from '../lib/i18n/I18nContext'; import { useCoreState } from '../providers/CoreStateProvider'; import type { ToastNotification } from '../types/intelligence'; @@ -421,7 +422,17 @@ export default function Brain() { /> ) : error !== null ? ( - ) : null} + ) : ( + // The first load can take seconds — a hosted engine + // builds the graph from its derived layers — and an + // empty canvas meanwhile reads as a broken page. +
+ +
+ )} )} diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index 9b8819e193c..1c92107ead0 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -111,6 +111,28 @@ describe('Brain page', () => { }); }); + it('shows a loading state, not an empty canvas, until the graph arrives', async () => { + let resolveGraph!: (graph: ReturnType) => void; + graphExportMock.mockReturnValue( + new Promise(resolve => { + resolveGraph = resolve; + }) + ); + await act(async () => { + renderWithProviders(, { initialEntries: ['/?tab=graph'] }); + }); + expect(screen.getByTestId('brain-graph-loading')).toHaveTextContent('workspace.loadingGraph'); + expect(screen.queryByTestId('memory-graph')).not.toBeInTheDocument(); + + await act(async () => { + resolveGraph(makeGraph(2)); + }); + await waitFor(() => { + expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:2'); + }); + expect(screen.queryByTestId('brain-graph-loading')).not.toBeInTheDocument(); + }); + it('renders empty-state graph when there are no nodes', async () => { graphExportMock.mockResolvedValue(makeGraph(0)); await act(async () => { diff --git a/crates/openhuman-app/Cargo.lock b/crates/openhuman-app/Cargo.lock index a6772bc645e..ad4ac1ad48a 100644 --- a/crates/openhuman-app/Cargo.lock +++ b/crates/openhuman-app/Cargo.lock @@ -6218,6 +6218,17 @@ dependencies = [ "socketioxide-core", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -7339,7 +7350,7 @@ dependencies = [ [[package]] name = "tinyconnectors" -version = "0.11.0" +version = "0.12.1" dependencies = [ "async-trait", "chrono", @@ -7358,7 +7369,7 @@ dependencies = [ [[package]] name = "tinyconnectors-bus" -version = "0.11.0" +version = "0.12.1" dependencies = [ "serde", "serde_json", @@ -7366,7 +7377,7 @@ dependencies = [ [[package]] name = "tinyconnectors-sync" -version = "0.11.0" +version = "0.12.1" dependencies = [ "async-trait", "chrono", @@ -7696,7 +7707,7 @@ dependencies = [ [[package]] name = "tinymemory" -version = "1.21.1" +version = "1.22.2" dependencies = [ "anyhow", "async-trait", @@ -8607,8 +8618,10 @@ dependencies = [ "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", + "socks", "ureq-proto", "utf8-zero", "webpki-roots 1.0.9", diff --git a/crates/openhuman-core/src/agent/error.rs b/crates/openhuman-core/src/agent/error.rs index 096986daefe..1f54cd27914 100644 --- a/crates/openhuman-core/src/agent/error.rs +++ b/crates/openhuman-core/src/agent/error.rs @@ -203,6 +203,47 @@ pub fn is_max_iterations_error(error_msg: &str) -> bool { error_msg.contains(MAX_ITERATIONS_ERROR_PREFIX) } +/// Which wall-clock bound ended a turn, as the host reports it to telemetry. +/// +/// Carried structurally from the harness (`HostedError::timeout_bound`) into +/// the turn error, because the hosted error message is sanitized and no longer +/// names the bound. The phrases below are the single place the host spells the +/// two bounds: the turn runner composes the error text from them and +/// `web_chat::ops::timeout_bound_tag` recognises that text through them, so +/// neither side restates the wording. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TurnTimeoutBound { + /// One model call exceeded the per-model-call ceiling. + PerModelCall, + /// The run's own remaining wall-clock budget ran out. + RunRemaining, +} + +impl TurnTimeoutBound { + /// Stable phrase embedded in the flattened turn error. + pub const fn phrase(self) -> &'static str { + match self { + Self::PerModelCall => "per-model-call ceiling", + Self::RunRemaining => "remaining wall-clock budget", + } + } + + /// Sentry `timeout_bound` tag value. + pub const fn tag(self) -> &'static str { + match self { + Self::PerModelCall => "per_model_call", + Self::RunRemaining => "run_remaining", + } + } + + /// Recover the bound from a flattened error string, if it names one. + pub fn from_message(message: &str) -> Option { + [Self::PerModelCall, Self::RunRemaining] + .into_iter() + .find(|bound| message.contains(bound.phrase())) + } +} + #[cfg(test)] #[path = "error_tests.rs"] mod tests; diff --git a/crates/openhuman-core/src/agent/learning/cache.rs b/crates/openhuman-core/src/agent/learning/cache.rs index ec9f1438a41..8dba322b1e1 100644 --- a/crates/openhuman-core/src/agent/learning/cache.rs +++ b/crates/openhuman-core/src/agent/learning/cache.rs @@ -4,9 +4,11 @@ //! The stability detector uses this to persist the result of each rebuild cycle. //! Prompt sections use [`FacetCache::list_active`] to read the ambient cache. +use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::agent::learning::candidate::FacetClass; +use crate::config::schema::MemorySubsystemConfig; use crate::memory::guard::MemoryGuard; use tinymemory_api::provider::{MemoryProfile, MemoryProvider, ProfileFacet, UserState}; @@ -30,17 +32,41 @@ pub struct FacetCache { /// Where a cache reads its facets from. /// -/// Production always takes [`Source::Guard`] — the bound driver, policy layer -/// included. [`Source::Direct`] exists for tests, which need somewhere to put -/// facets without standing up a driver; see +/// Production takes [`Source::Guard`] or [`Source::Workspace`] — the bound +/// driver, policy layer included. [`Source::Direct`] exists for tests, which +/// need somewhere to put facets without standing up a driver; see /// [`super::test_profile`] for why that is the right trade rather than parking /// the learning tests on a module artifact. enum Source { Guard(Arc), + /// Resolved again on every call; see [`FacetCache::for_workspace`]. + Workspace { + dir: PathBuf, + memory: MemorySubsystemConfig, + }, Direct(Arc), } +/// The driver one call reads through, held for that call. +enum Resolved<'a> { + Guard(Arc), + Direct(&'a dyn MemoryProfile), +} + +impl Resolved<'_> { + /// The profile family, or a caller-facing error. + fn profile(&self) -> anyhow::Result<&dyn MemoryProfile> { + match self { + Resolved::Guard(guard) => guard.as_profile().ok_or_else(|| { + anyhow::anyhow!("memory driver does not support the profile family") + }), + Resolved::Direct(profile) => Ok(*profile), + } + } +} + impl FacetCache { + /// A cache over one resolved driver, for the length of a request. #[must_use] pub fn new(guard: Arc) -> Self { Self { @@ -48,6 +74,30 @@ impl FacetCache { } } + /// A cache over whichever driver serves `workspace_dir`, resolved again on + /// every call. + /// + /// For a cache that outlives a request: the rebuild loop and the PROFILE.md + /// renderer hold theirs for the whole process. A cache over one resolved + /// guard keeps that driver after an engine switch, since a switch re-points + /// the workspace's context and only a new resolution sees the new engine + /// (`memory::binding_rebind`). Each call goes through the context serving + /// the workspace when there is one, so it follows a switch. Without one it + /// resolves the config the workspace is bound under now, which a switch + /// replaces, and only with nothing bound `memory`, the workspace's own + /// `[subsystems.memory]`. That is the case at boot, before the context + /// exists, where the default config would bind the local engine whatever + /// the user chose. + #[must_use] + pub fn for_workspace(workspace_dir: PathBuf, memory: MemorySubsystemConfig) -> Self { + Self { + source: Source::Workspace { + dir: workspace_dir, + memory, + }, + } + } + /// A cache over a caller-supplied profile family. /// /// Test-only: production must go through the guard so the policy layer is @@ -64,24 +114,23 @@ impl FacetCache { } } - /// The profile family, or a caller-facing error. - fn profile(&self) -> anyhow::Result<&dyn MemoryProfile> { + /// The driver this call reads through. + fn resolve(&self) -> anyhow::Result> { match &self.source { - Source::Guard(guard) => guard.as_profile().ok_or_else(|| { - anyhow::anyhow!("memory driver does not support the profile family") - }), - Source::Direct(profile) => Ok(profile.as_ref()), + Source::Guard(guard) => Ok(Resolved::Guard(Arc::clone(guard))), + Source::Workspace { dir, memory } => workspace_guard(dir, memory).map(Resolved::Guard), + Source::Direct(profile) => Ok(Resolved::Direct(profile.as_ref())), } } /// List all facets with `state = 'active'`, ordered by stability descending. pub async fn list_active(&self) -> anyhow::Result> { - Ok(self.profile()?.list_active_facets().await?) + Ok(self.resolve()?.profile()?.list_active_facets().await?) } /// List all facets (all states), ordered by stability descending. pub async fn list_all(&self) -> anyhow::Result> { - Ok(self.profile()?.list_all_facets().await?) + Ok(self.resolve()?.profile()?.list_all_facets().await?) } /// List active facets belonging to a specific class. @@ -98,12 +147,12 @@ impl FacetCache { /// Fetch a single facet by its full key (e.g. `"style/verbosity"`). pub async fn get(&self, key: &str) -> anyhow::Result> { - Ok(self.profile()?.get_facet(key).await?) + Ok(self.resolve()?.profile()?.get_facet(key).await?) } /// Upsert a fully-formed facet row (rebuild path). pub async fn upsert(&self, facet: &ProfileFacet) -> anyhow::Result<()> { - Ok(self.profile()?.upsert_facet(facet).await?) + Ok(self.resolve()?.profile()?.upsert_facet(facet).await?) } /// Override the `user_state` of a facet. @@ -111,6 +160,7 @@ impl FacetCache { /// Returns `Ok(true)` if a row was found and updated. pub async fn set_user_state(&self, key: &str, user_state: UserState) -> anyhow::Result { Ok(self + .resolve()? .profile()? .set_facet_user_state(key, user_state) .await?) @@ -118,17 +168,39 @@ impl FacetCache { /// Delete a facet by key. Returns `true` if a row was removed. pub async fn delete(&self, key: &str) -> anyhow::Result { - Ok(self.profile()?.delete_facet(key).await?) + Ok(self.resolve()?.profile()?.delete_facet(key).await?) } /// Delete all `Dropped`-state facets whose stability is below `threshold`. /// /// Pinned facets are never deleted. Returns the number of rows removed. pub async fn drop_below_threshold(&self, threshold: f64) -> anyhow::Result { - Ok(self.profile()?.drop_facets_below(threshold).await?) + Ok(self + .resolve()? + .profile()? + .drop_facets_below(threshold) + .await?) } } +/// The guarded driver serving `dir` now. +/// +/// Through the context that serves the workspace when there is one, which an +/// engine switch re-points. Else through the config the workspace is bound +/// under now, which a switch replaces: `memory` was the config when the cache +/// was built, and binding it again after a switch would rebuild the evicted +/// engine. Only with nothing bound, as at boot before the first binding, does +/// `memory` itself decide (`memory::binding::current_for`). +fn workspace_guard(dir: &Path, memory: &MemorySubsystemConfig) -> anyhow::Result> { + let context = crate::core::runtime::context::CoreContext::current() + .filter(|ctx| ctx.workspace_dir().ok().as_deref() == Some(dir)); + let guard = match context { + Some(ctx) => ctx.memory(), + None => crate::memory::binding::current_for(dir, memory).map(|binding| binding.guard()), + }; + guard.map_err(|error| anyhow::anyhow!("no memory binding for the facet cache: {error}")) +} + // ── Class ↔ key utilities ───────────────────────────────────────────────────── /// Extract the [`FacetClass`] from a full key string (e.g. `"style/verbosity"` → `Style`). diff --git a/crates/openhuman-core/src/agent/learning/startup.rs b/crates/openhuman-core/src/agent/learning/startup.rs index 5be437d03a4..1c4f58e1ca6 100644 --- a/crates/openhuman-core/src/agent/learning/startup.rs +++ b/crates/openhuman-core/src/agent/learning/startup.rs @@ -30,6 +30,8 @@ use std::sync::OnceLock; use tinybus::SubscriptionHandle; +use crate::config::schema::MemorySubsystemConfig; + static EMAIL_SIG_HANDLE: OnceLock> = OnceLock::new(); /// Register the always-on learning subscribers on the global event bus. @@ -41,8 +43,14 @@ static EMAIL_SIG_HANDLE: OnceLock> = OnceLock::new(); /// lifetime of the process (same pattern as `TracingSubscriber`). /// /// `workspace_dir` is the resolved workspace directory used by the -/// `ProfileMdRenderer` to locate `PROFILE.md`. -pub fn register_learning_subscribers(workspace_dir: std::path::PathBuf) { +/// `ProfileMdRenderer` to locate `PROFILE.md`, and `memory` its +/// `[subsystems.memory]` block: the engine learning reads and writes facets +/// through until the workspace's context exists. This runs at boot before it +/// does. +pub fn register_learning_subscribers( + workspace_dir: std::path::PathBuf, + memory: MemorySubsystemConfig, +) { // Phase 2 learning producer: email-signature subscriber reacts to // DocumentCanonicalized events and emits Identity candidates into the // buffer. Needs no memory client, so it always registers. @@ -57,8 +65,8 @@ pub fn register_learning_subscribers(workspace_dir: std::path::PathBuf) { // process globals. static CLIENT_HANDLES: OnceLock<(Option, Option)> = OnceLock::new(); - let memory_ready = memory_is_bindable(&workspace_dir); - CLIENT_HANDLES.get_or_init(|| register_with_memory(memory_ready, &workspace_dir)); + let memory_ready = memory_is_bindable(&workspace_dir, &memory); + CLIENT_HANDLES.get_or_init(|| register_with_memory(memory_ready, &workspace_dir, &memory)); } /// Whether this workspace has a usable memory driver to register learning @@ -76,8 +84,7 @@ pub fn register_learning_subscribers(workspace_dir: std::path::PathBuf) { /// negative here is `MemoryBinding::disables_memory`: memory explicitly /// configured off, which is the one state in which a rebuild loop has nothing /// to rebuild against. -fn memory_is_bindable(workspace_dir: &Path) -> bool { - use crate::config::schema::MemorySubsystemConfig; +fn memory_is_bindable(workspace_dir: &Path, memory: &MemorySubsystemConfig) -> bool { // An explicit `driver = "null"` advertises no capabilities at all, which is // how the context reports "memory is off" without exposing the binding. if let Some(ctx) = ambient_context_for(workspace_dir) { @@ -89,7 +96,7 @@ fn memory_is_bindable(workspace_dir: &Path) -> bool { } return true; } - match crate::memory::binding::for_workspace(workspace_dir, &MemorySubsystemConfig::default()) { + match crate::memory::binding::for_workspace(workspace_dir, memory) { Ok(binding) if binding.disables_memory() => { tracing::warn!( driver = %binding.driver_id(), @@ -115,8 +122,9 @@ fn memory_is_bindable(workspace_dir: &Path) -> bool { /// /// It carries the workspace's `[subsystems.memory]` config (including a /// memory-engine switch), so learning binds to the engine the user chose rather -/// than always to the default module. `None` means a bare boot with no context -/// for this workspace (unit tests), which falls back to the default config. +/// than always to the default module. `None` means no context serves this +/// workspace yet, as at boot, where the registration runs before the context +/// is installed: the caller's own copy of that config stands in. fn ambient_context_for( workspace_dir: &Path, ) -> Option> { @@ -143,38 +151,24 @@ where }); } -/// Register the client-dependent learning subscribers. -/// -/// The profile facet cache for `workspace_dir`. +/// The profile facet cache for `workspace_dir`, for a subscriber that lives as +/// long as the process: it resolves the workspace's driver on every call (see +/// [`FacetCache::for_workspace`]), so it reaches the engine the user chose at +/// boot and follows a switch made later. /// -/// Resolved through the memory binding rather than the process-global client: -/// facets live behind the driver now, and `binding::for_workspace` is -/// synchronous and cached, so this stays callable from the boot path without -/// an await. +/// [`FacetCache::for_workspace`]: crate::agent::learning::cache::FacetCache::for_workspace fn facet_cache_for( - workspace_dir: &std::path::Path, -) -> Option { - use crate::config::schema::MemorySubsystemConfig; - if let Some(ctx) = ambient_context_for(workspace_dir) { - return match ctx.memory() { - Ok(guard) => Some(crate::agent::learning::cache::FacetCache::new(guard)), - Err(error) => { - tracing::warn!("[learning::startup] no memory binding for facet cache: {error}"); - None - } - }; - } - match crate::memory::binding::for_workspace(workspace_dir, &MemorySubsystemConfig::default()) { - Ok(binding) => Some(crate::agent::learning::cache::FacetCache::new( - binding.guard(), - )), - Err(error) => { - tracing::warn!("[learning::startup] no memory binding for facet cache: {error}"); - None - } - } + workspace_dir: &Path, + memory: &MemorySubsystemConfig, +) -> crate::agent::learning::cache::FacetCache { + crate::agent::learning::cache::FacetCache::for_workspace( + workspace_dir.to_path_buf(), + memory.clone(), + ) } +/// Register the client-dependent learning subscribers. +/// /// Returns `(rebuild_trigger_handle, profile_md_renderer_handle)`. /// /// When `memory_ready` is true, both the Phase 3 rebuild trigger (plus its @@ -190,6 +184,7 @@ fn facet_cache_for( fn register_with_memory( memory_ready: bool, workspace_dir: &Path, + memory: &MemorySubsystemConfig, ) -> (Option, Option) { if !memory_ready { tracing::warn!( @@ -208,9 +203,7 @@ fn register_with_memory( use crate::agent::learning::scheduler::register_event_trigger; use crate::agent::learning::StabilityDetector; use std::sync::Arc; - let Some(cache) = facet_cache_for(workspace_dir) else { - return (None, None); - }; + let cache = facet_cache_for(workspace_dir, memory); let detector = Arc::new(StabilityDetector::new(cache).persisted_in(workspace_dir)); // Also spawn the periodic rebuild loop (30-minute cadence). let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -239,10 +232,7 @@ fn register_with_memory( let profile_md = { use crate::agent::learning::ProfileMdRenderer; use std::sync::Arc; - let Some(cache) = facet_cache_for(workspace_dir) else { - return (rebuild_trigger, None); - }; - let cache = Arc::new(cache); + let cache = Arc::new(facet_cache_for(workspace_dir, memory)); let renderer = Arc::new(ProfileMdRenderer::new(cache, workspace_dir.to_path_buf())); let handle = ProfileMdRenderer::subscribe(renderer); if handle.is_some() { diff --git a/crates/openhuman-core/src/agent/learning/startup_tests.rs b/crates/openhuman-core/src/agent/learning/startup_tests.rs index b13226a1d72..f9879284b3a 100644 --- a/crates/openhuman-core/src/agent/learning/startup_tests.rs +++ b/crates/openhuman-core/src/agent/learning/startup_tests.rs @@ -4,6 +4,8 @@ use crate::agent::learning::extract::signature::{ parse_signature, register_email_signature_subscriber_on, }; use crate::core::events::DomainEvent; +use crate::memory::guard::test_support::RecordingProvider; +use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; @@ -61,7 +63,8 @@ async fn wait_for_candidates(buffer: &Buffer, expected: usize) -> usize { async fn register_with_memory_registers_both_handles_when_ready() { crate::core::bus::init().await.expect("bus init"); let tmp = test_workspace(); - let (trigger, renderer) = register_with_memory(true, tmp.path()); + let (trigger, renderer) = + register_with_memory(true, tmp.path(), &MemorySubsystemConfig::default()); assert!( trigger.is_some(), "rebuild trigger must register when memory is available" @@ -78,7 +81,8 @@ async fn register_with_memory_skips_and_warns_when_memory_absent() { // the (now loud) warn path is exercised. This is the else-arm the #5003 // fix upgraded from a silent debug-level skip. let tmp = TempDir::new().expect("tempdir"); - let (trigger, renderer) = register_with_memory(false, tmp.path()); + let (trigger, renderer) = + register_with_memory(false, tmp.path(), &MemorySubsystemConfig::default()); assert!(trigger.is_none(), "no trigger without a memory driver"); assert!(renderer.is_none(), "no renderer without a memory driver"); } @@ -133,3 +137,101 @@ async fn register_learning_subscribers_is_idempotent() { "double registration must not double the pushed candidates (#5003 idempotency)" ); } + +/// An engine the test binds itself, so nothing reaches for the module. +fn chosen_engine() -> MemorySubsystemConfig { + MemorySubsystemConfig { + driver: "tinyhumans".into(), + ..MemorySubsystemConfig::default() + } +} + +fn reached_profile(provider: &RecordingProvider) -> bool { + provider + .calls() + .iter() + .any(|call| call.method == "profile.list_all_facets") +} + +/// Learning registers at boot, before the workspace's context exists, so it +/// asks the binding for the config the boot path hands it: the engine the user +/// chose reads as ready, and memory turned off reads as off. +#[test] +fn memory_readiness_comes_from_the_workspaces_own_config() { + let tmp = test_workspace(); + let chosen = chosen_engine(); + crate::memory::binding::install_for_test( + tmp.path(), + &chosen, + Arc::new(RecordingProvider::new()), + ); + assert!(memory_is_bindable(tmp.path(), &chosen)); + + let off = MemorySubsystemConfig { + driver: "null".into(), + ..MemorySubsystemConfig::default() + }; + assert!(!memory_is_bindable(tmp.path(), &off)); +} + +/// The rebuild loop and the PROFILE.md renderer keep their cache for the whole +/// process. It reads through the engine the workspace's config names, and an +/// engine bound for the workspace later, as a switch binds one, is the one its +/// next call reaches. +#[tokio::test] +async fn a_long_lived_facet_cache_reads_the_engine_the_workspace_has_now() { + let tmp = test_workspace(); + let chosen = chosen_engine(); + let first = Arc::new(RecordingProvider::new()); + crate::memory::binding::install_for_test(tmp.path(), &chosen, first.clone()); + let cache = facet_cache_for(tmp.path(), &chosen); + cache + .list_all() + .await + .expect("facets through the chosen engine"); + assert!(reached_profile(&first)); + + let second = Arc::new(RecordingProvider::new()); + crate::memory::binding::install_for_test(tmp.path(), &chosen, second.clone()); + cache + .list_all() + .await + .expect("facets through the engine bound since"); + assert!( + reached_profile(&second), + "a cache built before the switch must reach the engine bound after it" + ); +} + +/// With no context serving the workspace, a switch still reaches the cache: +/// it takes the binding the switch left, not the config it was built with, +/// which would bind the engine switched away from again. +#[tokio::test] +async fn a_long_lived_facet_cache_follows_a_switch_made_without_a_context() { + let tmp = test_workspace(); + let before = MemorySubsystemConfig { + driver: "null".into(), + ..MemorySubsystemConfig::default() + }; + let first = Arc::new(RecordingProvider::new()); + crate::memory::binding::install_for_test(tmp.path(), &before, first.clone()); + let cache = facet_cache_for(tmp.path(), &before); + cache + .list_all() + .await + .expect("facets through the engine bound at first"); + assert!(reached_profile(&first)); + + let after = chosen_engine(); + let second = Arc::new(RecordingProvider::new()); + crate::memory::binding::install_for_test(tmp.path(), &after, second.clone()); + crate::memory::binding::rebind(tmp.path(), &before.driver, &after).expect("switch"); + cache + .list_all() + .await + .expect("facets through the engine switched to"); + assert!( + reached_profile(&second), + "the cache must follow the switch, not rebind the config it was built with" + ); +} diff --git a/crates/openhuman-core/src/agent/message_convert.rs b/crates/openhuman-core/src/agent/message_convert.rs index 40fad598652..2294ff06779 100644 --- a/crates/openhuman-core/src/agent/message_convert.rs +++ b/crates/openhuman-core/src/agent/message_convert.rs @@ -1,30 +1,31 @@ -//! Persistence-boundary conversions between OpenHuman transcript records and -//! TinyAgents' rich [`Message`]/[`TaToolCall`] types. +//! Conversions between OpenHuman's transcript rows and TinyAgents' rich +//! [`Message`]/[`TaToolCall`] types. //! -//! The two sides model the same concepts with different shapes: -//! -//! - openhuman `TranscriptMessage` is `{ role: String, content: String }` — tool -//! calls and tool-result correlation ids are not first-class fields; the -//! legacy loop threads them through provider-native encoding instead. -//! - `tinyagents::harness::message::Message` is a typed enum -//! (`System`/`User`/`Assistant`/`Tool`) whose `Assistant` arm carries -//! structured `tool_calls` and whose `Tool` arm carries a `tool_call_id`. +//! The row ([`TranscriptMessage`]) is typed: `content` is plain text, a native +//! assistant tool round lives in `tool_calls`, a tool result carries its +//! `tool_call_id`, and a user turn with pictures carries ordered `parts`. The +//! harness [`Message`] is a typed enum (`System`/`User`/`Assistant`/`Tool`) +//! whose `Assistant` arm carries structured `tool_calls` and whose `Tool` arm +//! carries a `tool_call_id`. The two map onto each other field for field; there +//! is no string envelope in between (the legacy envelope and image-marker forms +//! are lifted into the typed row when a transcript is read, and rebuilt only by +//! the compatibility adapters in [`crate::agent::messages`] and the journal +//! projector). //! //! These helpers bridge the seed history into the harness and the harness' //! resulting transcript back out, so a turn can run on the `tinyagents` -//! agent-loop while callers keep speaking openhuman's `TranscriptMessage` vocabulary. +//! agent-loop while callers keep speaking the row vocabulary. use tinyinference_llm::message::{ AssistantMessage, ContentBlock, ImageRef, Message, SystemMessage, ToolMessage, UserMessage, }; use tinyinference_llm::tool::ToolCall as TaToolCall; use tinytools_agent::dialect::{ - encode_assistant_envelope, encode_tool_envelope, parse_assistant_envelope, parse_tool_envelope, DialectMessage, DialectResponse, DialectRole, ToolDialect, ToolResultEntry, TranscriptEntry, }; use crate::inference::provider::ChatResponse; -use tinyagents_session::transcript::TranscriptMessage; +use tinyagents_session::transcript::{TranscriptMessage, TranscriptPart, TranscriptToolCall}; /// Convert the host provider response at its boundary into the canonical /// dialect input. The dialect crate owns all parsing after this field-wise map. @@ -36,19 +37,20 @@ pub(crate) fn dialect_response_from_provider(response: &ChatResponse) -> Dialect } /// Replay typed conversation entries through a canonical dialect and return -/// the provider's message rows. +/// the provider's typed rows (a native tool round keeps its calls and call ids +/// in fields, never packed into `content`). pub(crate) fn provider_messages_from_conversation( dialect: &dyn ToolDialect, history: &[TranscriptEntry], ) -> Vec { dialect - .to_provider_messages(history) + .to_typed_messages(history) .into_iter() .map(dialect_message_to_row) .collect() } -/// A flat row as the dialect's chat entry: the typed role, the body and the +/// A row as the dialect's chat entry: the typed role, the body and the /// passthrough metadata; the row's other fields have no dialect counterpart. pub(crate) fn row_to_dialect_message(row: TranscriptMessage) -> DialectMessage { DialectMessage { @@ -60,12 +62,17 @@ pub(crate) fn row_to_dialect_message(row: TranscriptMessage) -> DialectMessage { }, content: row.content, extra_metadata: row.extra_metadata, + tool_calls: row.tool_calls.into_iter().map(Into::into).collect(), + tool_call_id: row.tool_call_id, + reasoning_content: None, } } fn dialect_message_to_row(message: DialectMessage) -> TranscriptMessage { let mut row = TranscriptMessage::new(message.role.as_str(), message.content); row.extra_metadata = message.extra_metadata; + row.tool_calls = message.tool_calls.into_iter().map(Into::into).collect(); + row.tool_call_id = message.tool_call_id; row } @@ -128,18 +135,16 @@ fn reasoning_extra_metadata(content: &[ContentBlock]) -> Option Message { let text = msg.content.clone(); match msg.role.as_str() { @@ -158,49 +163,114 @@ pub(crate) fn chat_message_to_message(msg: &TranscriptMessage) -> Message { .as_ref() .and_then(|meta| meta.get(REASONING_EXT_KEY)) .and_then(serde_json::Value::as_str); - if let Some(envelope) = parse_assistant_envelope(&text) { - let tool_calls = envelope.tool_calls.iter().map(oh_call_to_ta_call).collect(); - let mut content = vec![ContentBlock::Text(envelope.content)]; - content.extend(reasoning_content_block(reasoning)); - Message::Assistant(AssistantMessage { - id: msg.id.clone(), - content, - tool_calls, - usage: None, - origin: None, - }) - } else { - let mut content = vec![ContentBlock::Text(text)]; - content.extend(reasoning_content_block(reasoning)); - Message::Assistant(AssistantMessage { - id: msg.id.clone(), - content, - tool_calls: Vec::new(), - usage: None, - origin: None, - }) - } - } - "tool" => { - // Prefer the envelope's `tool_call_id` (the native seed shape); fall - // back to the message id, then an empty id for a bare tool message. - let (tool_call_id, content) = parse_tool_envelope(&text) - .unwrap_or_else(|| (msg.id.clone().unwrap_or_default(), text.clone())); - Message::Tool(ToolMessage { - tool_call_id, - content: vec![ContentBlock::Text(content)], - trusted_verbatim: false, - artifact: None, + let mut content = vec![ContentBlock::Text(text)]; + content.extend(reasoning_content_block(reasoning)); + Message::Assistant(AssistantMessage { + id: msg.id.clone(), + content, + tool_calls: msg.tool_calls.iter().map(row_call_to_ta_call).collect(), + usage: None, + origin: None, }) } + "tool" => Message::Tool(ToolMessage { + tool_call_id: msg + .tool_call_id + .clone() + .or_else(|| msg.id.clone()) + .unwrap_or_default(), + content: vec![ContentBlock::Text(text)], + trusted_verbatim: false, + artifact: None, + }), // "user" and any unrecognized role default to a user turn — the safest // mapping for a free-form inbound message. _ => Message::User(UserMessage { - content: user_content_blocks(text), + content: match msg.parts.as_deref() { + Some(parts) => user_blocks_from_parts(parts), + None => user_content_blocks(text), + }, }), } } +/// The content blocks of a user row's typed `parts`, in source order. +fn user_blocks_from_parts(parts: &[TranscriptPart]) -> Vec { + let blocks: Vec = parts + .iter() + .map(|part| match part { + TranscriptPart::Text { text } => ContentBlock::Text(text.clone()), + TranscriptPart::Image { url } => ContentBlock::Image(ImageRef { + url: url.clone(), + mime_type: data_uri_mime(url), + }), + }) + .collect(); + if blocks.is_empty() { + vec![ContentBlock::Text(String::new())] + } else { + blocks + } +} + +/// A user [`Message`] from the text a person (or a channel) sent: each +/// `[IMAGE:]` marker whose payload is a provider-ready reference becomes a +/// typed [`ContentBlock::Image`], so the turn is stored and replayed with real +/// image parts instead of a text marker. The surrounding text is kept verbatim +/// (unlike the provider-bound lift in [`user_content_blocks`], which trims), so +/// [`user_text_with_markers`] gives back exactly the text that came in. Text +/// without a ready marker is a plain text message, byte for byte. +pub(crate) fn user_message_from_text(text: &str) -> Message { + const PREFIX: &str = "[IMAGE:"; + if !text.contains(PREFIX) { + return Message::user(text); + } + let mut blocks: Vec = Vec::new(); + let mut pending = String::new(); + let mut rest = text; + let mut images = 0usize; + while let Some(start) = rest.find(PREFIX) { + let after = &rest[start + PREFIX.len()..]; + let Some(end) = after.find(']') else { + break; + }; + let payload = after[..end].trim(); + if is_provider_ready_image_reference(payload) { + pending.push_str(&rest[..start]); + if !pending.is_empty() { + blocks.push(ContentBlock::Text(std::mem::take(&mut pending))); + } + blocks.push(ContentBlock::Image(ImageRef { + url: payload.to_string(), + mime_type: data_uri_mime(payload), + })); + images += 1; + } else { + pending.push_str(&rest[..start + PREFIX.len() + end + 1]); + } + rest = &after[end + 1..]; + } + if images == 0 { + return Message::user(text); + } + pending.push_str(rest); + if !pending.is_empty() { + blocks.push(ContentBlock::Text(pending)); + } + log::debug!("[agent][message_convert] stored {images} image attachment(s) as typed parts"); + Message::User(UserMessage { content: blocks }) +} + +/// The inverse of [`user_message_from_text`]: a user message's text with each +/// image block rendered back as an `[IMAGE:]` marker. A text-only message +/// is exactly [`Message::text`]. +pub(crate) fn user_text_with_markers(msg: &Message) -> String { + match msg { + Message::User(user) => user_row(msg, user).display_content(), + other => other.text(), + } +} + /// Build the content blocks for a user turn, lifting any `[IMAGE:…]` markers out /// of the text into typed [`ContentBlock::Image`] blocks. /// @@ -300,17 +370,27 @@ fn data_uri_mime(reference: &str) -> Option { (!mime.is_empty()).then(|| mime.to_string()) } -/// Inverse of [`ta_call_to_oh_call`]: rebuild a harness [`TaToolCall`] from an -/// openhuman [`NativeToolCall`] (whose `arguments` is a serialized JSON string). -fn oh_call_to_ta_call(oh: &tinytools_agent::dialect::NativeToolCall) -> TaToolCall { +/// Rebuild a harness [`TaToolCall`] from a row's [`TranscriptToolCall`] (whose +/// `arguments` is the serialized JSON string the provider emitted). +fn row_call_to_ta_call(call: &TranscriptToolCall) -> TaToolCall { TaToolCall { - id: oh.id.clone(), - name: oh.name.clone(), - arguments: serde_json::from_str(&oh.arguments).unwrap_or(serde_json::Value::Null), + id: call.id.clone(), + name: call.name.clone(), + arguments: serde_json::from_str(&call.arguments).unwrap_or(serde_json::Value::Null), invalid: None, } } +/// A harness [`TaToolCall`] as a row's [`TranscriptToolCall`]. +fn ta_call_to_row_call(call: &TaToolCall) -> TranscriptToolCall { + TranscriptToolCall { + id: call.id.clone(), + name: call.name.clone(), + arguments: call.arguments.to_string(), + extra_content: None, + } +} + /// Convert a seed history into the harness `input` transcript. pub(crate) fn history_to_messages(history: &[TranscriptMessage]) -> Vec { history.iter().map(chat_message_to_message).collect() @@ -357,58 +437,15 @@ pub(crate) fn messages_to_history(messages: &[Message]) -> Vec]` markers. This is the inverse of [`user_content_blocks`]: the -/// forward hop lifts `[IMAGE:…]` markers into typed [`ContentBlock::Image`] -/// blocks, so the reverse hop must re-emit them, or a native-tool provider that -/// round-trips through a string-content [`TranscriptMessage`] (claude-code, and any -/// other `supports_native_tools` provider) silently loses every pasted image — -/// [`Message::text`] concatenates only [`ContentBlock::Text`] and drops the rest. -/// The marker-aware provider input builders reinflate `[IMAGE:data:…]` back into -/// real image content blocks. Text-only turns are byte-for-byte identical to -/// `msg.text()` (fast path), so non-image traffic is unaffected. -fn native_user_content(msg: &Message) -> String { - let Message::User(user) = msg else { - return msg.text(); - }; - // Fast path: no image blocks → identical to `msg.text()`. - if !user - .content - .iter() - .any(|b| matches!(b, ContentBlock::Image(_))) - { - return msg.text(); - } - let mut out = String::new(); - for block in &user.content { - let piece = match block { - ContentBlock::Text(text) => text.replace("[OH_IMAGE:", "[OH_IMAGE_LITERAL:"), - // Use a private wire marker so provider input builders can - // distinguish an image block from literal text that happens to - // look like `[IMAGE:…]`. - ContentBlock::Image(image) => format!("[OH_IMAGE:{}]", image.url), - // Json / ProviderExtension carry no user-visible text — `msg.text()` - // drops them too, so skip them to preserve that behaviour. - _ => continue, - }; - if piece.is_empty() { - continue; - } - out.push_str(&piece); - } - out -} - -/// Convert one harness [`Message`] into a [`TranscriptMessage`] for a **native** -/// tool-calling provider request, preserving the structure the provider needs to -/// round-trip a tool round: an assistant turn that made tool calls is encoded as -/// the `{ "content", "tool_calls" }` JSON envelope (matching the dispatcher's -/// native `to_provider_messages`), and a tool result as `{ "tool_call_id", -/// "content" }`. Without this the provider sees an assistant with no `tool_calls` -/// followed by an orphan tool message and drops the round — breaking multi-turn -/// native tool calling (e.g. the orchestrator's `spawn_parallel_agents` → -/// synthesis hop). +/// Convert one harness [`Message`] into a typed row for a **native** +/// tool-calling provider request, preserving the structure the provider needs +/// to round-trip a tool round: an assistant turn that made tool calls keeps +/// them in `tool_calls`, a tool result keeps its `tool_call_id`, and a user turn +/// with pictures keeps its text and image blocks as ordered `parts`. Without +/// this the provider sees an assistant with no `tool_calls` followed by an +/// orphan tool message and drops the round — breaking multi-turn native tool +/// calling (e.g. the orchestrator's `spawn_parallel_agents` → synthesis hop) — +/// and a native-tool provider silently loses every pasted image. /// /// Returns `None` for [`Message::Custom`]: that variant is a host-side /// out-of-band record (compaction marker, label, audit note) that the harness @@ -416,25 +453,17 @@ fn native_user_content(msg: &Message) -> String { pub(crate) fn message_to_native_chat_message(msg: &Message) -> Option { Some(match msg { Message::System(_) => TranscriptMessage::system(msg.text()), - Message::User(_) => TranscriptMessage::user(native_user_content(msg)), - Message::Assistant(a) if !a.tool_calls.is_empty() => { - let tool_calls: Vec<_> = a.tool_calls.iter().map(ta_call_to_oh_call).collect(); - let mut cm = TranscriptMessage::assistant(encode_assistant_envelope( - Some(&msg.text()), - &tool_calls, - None, - )); - cm.extra_metadata = reasoning_extra_metadata(&a.content); - cm - } + Message::User(user) => user_row(msg, user), Message::Assistant(a) => { - let mut cm = TranscriptMessage::assistant(msg.text()); + let mut cm = TranscriptMessage::assistant_with_calls( + msg.text(), + a.tool_calls.iter().map(ta_call_to_row_call).collect(), + ); cm.extra_metadata = reasoning_extra_metadata(&a.content); cm } Message::Tool(t) => { - let mut cm = - TranscriptMessage::tool(encode_tool_envelope(&t.tool_call_id, &msg.text())); + let mut cm = TranscriptMessage::tool_result(t.tool_call_id.clone(), msg.text()); cm.id = Some(t.tool_call_id.clone()); cm } @@ -445,6 +474,33 @@ pub(crate) fn message_to_native_chat_message(msg: &Message) -> Option TranscriptMessage { + let has_image = user + .content + .iter() + .any(|block| matches!(block, ContentBlock::Image(_))); + if !has_image { + return TranscriptMessage::user(msg.text()); + } + TranscriptMessage::user_with_parts( + user.content + .iter() + .filter_map(|block| match block { + ContentBlock::Text(text) if !text.is_empty() => { + Some(TranscriptPart::Text { text: text.clone() }) + } + ContentBlock::Image(image) => Some(TranscriptPart::Image { + url: image.url.clone(), + }), + _ => None, + }) + .collect(), + ) +} + /// Convert a harness transcript into typed dialect entries, preserving /// assistant tool-call structure (`AssistantToolCalls`) and tool results /// (`ToolResults`) instead of flattening tool calls to text. @@ -466,11 +522,7 @@ pub(crate) fn messages_to_conversation(messages: &[Message]) -> Vec, ) -> TranscriptEntry { - TranscriptEntry::Chat(DialectMessage { - role, - content, - extra_metadata, - }) + TranscriptEntry::Chat(DialectMessage::new(role, content).with_metadata(extra_metadata)) } for msg in messages { diff --git a/crates/openhuman-core/src/agent/message_convert_tests.rs b/crates/openhuman-core/src/agent/message_convert_tests.rs index 9201eb95938..c0c4ac41905 100644 --- a/crates/openhuman-core/src/agent/message_convert_tests.rs +++ b/crates/openhuman-core/src/agent/message_convert_tests.rs @@ -136,24 +136,20 @@ fn plain_user_text_stays_a_single_text_block() { } #[test] -fn seeded_native_tool_round_recovers_structure_and_round_trips() { - use tinytools_agent::dialect::NativeToolCall as OhToolCall; - // The native dispatcher seeds an assistant tool round as a - // {content, tool_calls} envelope followed by {tool_call_id, content} rows. - let oh_call = OhToolCall { - id: "call-1".into(), - name: "echo".into(), - arguments: r#"{"msg":"hi"}"#.into(), - extra_content: None, - }; - let assistant_cm = TranscriptMessage::assistant( - serde_json::json!({ "content": "calling echo", "tool_calls": [oh_call] }).to_string(), - ); - let tool_cm = TranscriptMessage::tool( - serde_json::json!({ "tool_call_id": "call-1", "content": "echoed:hi" }).to_string(), +fn typed_native_tool_round_maps_to_structured_messages_and_back() { + // A native tool round is carried in typed fields: calls on the assistant + // row, the answered call id on the tool row, plain text in `content`. + let assistant_cm = TranscriptMessage::assistant_with_calls( + "calling echo", + vec![TranscriptToolCall { + id: "call-1".into(), + name: "echo".into(), + arguments: r#"{"msg":"hi"}"#.into(), + extra_content: None, + }], ); + let tool_cm = TranscriptMessage::tool_result("call-1", "echoed:hi"); - // Inbound: the envelopes are recovered into structured harness messages. let a = chat_message_to_message(&assistant_cm); let Message::Assistant(am) = &a else { panic!("expected Assistant, got {a:?}"); @@ -175,19 +171,74 @@ fn seeded_native_tool_round_recovers_structure_and_round_trips() { assert!(!tm.trusted_verbatim); assert_eq!(t.text(), "echoed:hi"); - // Outbound: re-serialized to a well-formed native tool round (assistant - // carries structured tool_calls, the tool row carries the matching id). + // Back out: the same typed rows, no envelope string anywhere. let a_native = message_to_native_chat_message(&a).expect("assistant converts"); assert_eq!(a_native.role.as_str(), "assistant"); - let av: serde_json::Value = serde_json::from_str(&a_native.content).unwrap(); - assert_eq!(av["tool_calls"][0]["id"], "call-1"); - assert_eq!(av["content"], "calling echo"); + assert_eq!(a_native.content, "calling echo"); + assert_eq!(a_native.tool_calls, assistant_cm.tool_calls); let t_native = message_to_native_chat_message(&t).expect("tool converts"); assert_eq!(t_native.role.as_str(), "tool"); - let tv: serde_json::Value = serde_json::from_str(&t_native.content).unwrap(); - assert_eq!(tv["tool_call_id"], "call-1"); - assert_eq!(tv["content"], "echoed:hi"); + assert_eq!(t_native.content, "echoed:hi"); + assert_eq!(t_native.tool_call_id.as_deref(), Some("call-1")); + assert_eq!(t_native.id.as_deref(), Some("call-1")); +} + +/// A legacy envelope row (what an older release wrote) reads into the same +/// model messages as its typed form once the reader has lifted it. +#[test] +fn legacy_envelope_rows_lift_to_the_same_messages_as_typed_rows() { + let envelope = r#"{"content":"calling echo","tool_calls":[{"id":"call-1","name":"echo","arguments":"{\"msg\":\"hi\"}"}]}"#; + let tool = r#"{"tool_call_id":"call-1","content":"echoed:hi"}"#; + let typed = vec![ + TranscriptMessage::assistant_with_calls( + "calling echo", + vec![TranscriptToolCall { + id: "call-1".into(), + name: "echo".into(), + arguments: r#"{"msg":"hi"}"#.into(), + extra_content: None, + }], + ), + TranscriptMessage::tool_result("call-1", "echoed:hi"), + ]; + let lifted = vec![ + TranscriptMessage::from_legacy("assistant", envelope), + TranscriptMessage::from_legacy("tool", tool), + ]; + assert_eq!(history_to_messages(&lifted), history_to_messages(&typed)); + // ...and the lifted rows hand the exact original strings back. + assert_eq!(lifted[0].legacy_content(), envelope); + assert_eq!(lifted[1].legacy_content(), tool); +} + +#[test] +fn user_text_with_ready_markers_is_stored_as_typed_image_parts() { + let png = "data:image/png;base64,iVBORw0KGgo="; + let text = format!("look [IMAGE:{png}] and [IMAGE:/local/path.png] please"); + let msg = user_message_from_text(&text); + let Message::User(user) = &msg else { + panic!("user message"); + }; + // The path marker is not provider-ready: it stays in the surrounding text. + assert_eq!(user.content.len(), 3); + assert!(matches!(&user.content[0], ContentBlock::Text(t) if t == "look ")); + assert!(matches!(&user.content[1], ContentBlock::Image(i) if i.url == png)); + assert!( + matches!(&user.content[2], ContentBlock::Text(t) if t == " and [IMAGE:/local/path.png] please") + ); + // The row keeps the parts; `content` is the text. + let row = message_to_native_chat_message(&msg).expect("row"); + assert_eq!(row.parts.as_ref().map(Vec::len), Some(3)); + assert_eq!(row.content, "look and [IMAGE:/local/path.png] please"); + // The text comes back exactly, markers in place. + assert_eq!(user_text_with_markers(&msg), text); + // A row round-trips to the same message. + assert_eq!(chat_message_to_message(&row), msg); + // Text without a ready marker is a plain text message. + for plain in ["hello", "see [IMAGE:/p.png]", "dangling [IMAGE:data:x"] { + assert_eq!(user_message_from_text(plain), Message::user(plain)); + } } #[test] diff --git a/crates/openhuman-core/src/agent/messages.rs b/crates/openhuman-core/src/agent/messages.rs index 0f1bcb30945..0f42f9cbade 100644 --- a/crates/openhuman-core/src/agent/messages.rs +++ b/crates/openhuman-core/src/agent/messages.rs @@ -14,6 +14,12 @@ //! [`history_wire`] keeps writing exactly that shape, so a binary of either //! generation reads what the other wrote, and reads any row shape (the //! vendor row is a superset whose extra fields default). +//! +//! The in-memory row is typed (tool calls, call ids and image parts are +//! fields, `content` is plain text), but these files stay the flat string +//! pairs they always were: a write rebuilds the established string form +//! (`legacy_content`: the native envelope for a tool round, `[IMAGE:]` +//! markers for image parts) and a read lifts it back into the typed row. use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -25,6 +31,23 @@ struct WireRow<'a> { content: &'a str, } +/// The flat `content` string a typed row is stored as. Image parts use the +/// host's own `[IMAGE:]` marker (which both generations read), not the +/// private native-wire marker. +fn wire_content(row: &TranscriptMessage) -> String { + if row.parts.is_some() { + row.display_content() + } else { + row.legacy_content() + } +} + +/// A deserialized row with any string-encoded structure lifted into the typed +/// fields. +fn lifted(row: TranscriptMessage) -> TranscriptMessage { + row.normalized() +} + fn serialize_rows( rows: &[TranscriptMessage], serializer: S, @@ -33,7 +56,7 @@ fn serialize_rows( for row in rows { seq.serialize_element(&WireRow { role: &row.role, - content: &row.content, + content: &wire_content(row), })?; } seq.end() @@ -53,7 +76,10 @@ pub mod history_wire { pub fn deserialize<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { - Vec::::deserialize(deserializer) + Ok(Vec::::deserialize(deserializer)? + .into_iter() + .map(lifted) + .collect()) } /// The same contract for an `Option>` field. @@ -73,7 +99,8 @@ pub mod history_wire { pub fn deserialize<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result>, D::Error> { - Option::>::deserialize(deserializer) + Ok(Option::>::deserialize(deserializer)? + .map(|rows| rows.into_iter().map(lifted).collect())) } } } diff --git a/crates/openhuman-core/src/agent/messages_tests.rs b/crates/openhuman-core/src/agent/messages_tests.rs index 9027c402cf0..8bf3f7c53a8 100644 --- a/crates/openhuman-core/src/agent/messages_tests.rs +++ b/crates/openhuman-core/src/agent/messages_tests.rs @@ -1,5 +1,6 @@ use super::*; use serde::{Deserialize, Serialize}; +use tinyagents_session::transcript::TranscriptPart; #[derive(Serialize, Deserialize)] struct Holder { @@ -11,7 +12,9 @@ struct Holder { #[test] fn history_wire_writes_only_role_and_content() { - let mut row = TranscriptMessage::tool("{\"tool_call_id\":\"c1\",\"content\":\"ok\"}"); + // A typed tool row is written as the flat envelope string files have + // always held. + let mut row = TranscriptMessage::tool_result("c1", "ok"); row.id = Some("c1".into()); row.cache_breakpoints = vec![3, 7]; row.extra_metadata = Some(serde_json::json!({"reasoning_content": "why"})); @@ -19,13 +22,23 @@ fn history_wire_writes_only_role_and_content() { rows: vec![row], maybe: None, }; + let written = serde_json::to_value(&holder).unwrap(); + let rows = written["rows"].as_array().unwrap(); + assert_eq!(rows.len(), 1); assert_eq!( - serde_json::to_value(&holder).unwrap(), - serde_json::json!({ - "rows": [{"role": "tool", "content": "{\"tool_call_id\":\"c1\",\"content\":\"ok\"}"}], - "maybe": null, - }) + rows[0].as_object().unwrap().keys().collect::>(), + ["content", "role"] ); + assert_eq!(rows[0]["role"], "tool"); + // Compared as JSON: key order inside the envelope depends on whether a + // downstream build unifies serde_json's `preserve_order`. + let envelope: serde_json::Value = + serde_json::from_str(rows[0]["content"].as_str().unwrap()).unwrap(); + assert_eq!( + envelope, + serde_json::json!({"tool_call_id": "c1", "content": "ok"}) + ); + assert!(written["maybe"].is_null()); } #[test] @@ -49,3 +62,52 @@ fn history_wire_option_defaults_to_none_when_absent() { let holder: Holder = serde_json::from_value(serde_json::json!({"rows": []})).unwrap(); assert!(holder.rows.is_empty() && holder.maybe.is_none()); } + +#[test] +fn history_wire_lifts_flat_strings_into_typed_rows_and_hands_them_back_exactly() { + // Key order and `null` content are the writer's, not ours: the string a + // file held is the string it keeps. + let envelope = + "{\"content\":null,\"tool_calls\":[{\"id\":\"c1\",\"name\":\"n\",\"arguments\":\"{}\"}]}"; + let tool = "{\"tool_call_id\":\"c1\",\"content\":\"ok\"}"; + let raw = serde_json::json!({ + "rows": [ + {"role": "assistant", "content": envelope}, + {"role": "tool", "content": tool}, + {"role": "user", "content": "see [IMAGE:data:image/png;base64,AA==] there"}, + ], + "maybe": null, + }); + let holder: Holder = serde_json::from_value(raw.clone()).unwrap(); + assert_eq!(holder.rows[0].tool_calls.len(), 1); + assert_eq!(holder.rows[0].content, ""); + assert_eq!(holder.rows[1].tool_call_id.as_deref(), Some("c1")); + assert_eq!(holder.rows[1].content, "ok"); + // A host-marker image stays text on the wire (the lift to image blocks is + // the message bridge's). + assert!(holder.rows[2].parts.is_none()); + assert_eq!(serde_json::to_value(&holder).unwrap(), raw); +} + +#[test] +fn history_wire_writes_image_parts_as_host_markers() { + let row = TranscriptMessage::user_with_parts(vec![ + TranscriptPart::Text { + text: "see ".into(), + }, + TranscriptPart::Image { + url: "data:image/png;base64,AA==".into(), + }, + TranscriptPart::Text { + text: " there".into(), + }, + ]); + let holder = Holder { + rows: vec![row], + maybe: None, + }; + assert_eq!( + serde_json::to_value(&holder).unwrap()["rows"][0]["content"], + "see [IMAGE:data:image/png;base64,AA==] there" + ); +} diff --git a/crates/openhuman-core/src/agent/multimodal.rs b/crates/openhuman-core/src/agent/multimodal.rs index 71791822e92..1313459d209 100644 --- a/crates/openhuman-core/src/agent/multimodal.rs +++ b/crates/openhuman-core/src/agent/multimodal.rs @@ -38,7 +38,7 @@ use reqwest::Client; use crate::config::{ build_runtime_proxy_client_with_timeouts, MultimodalConfig, MultimodalFileConfig, }; -use tinyagents_session::transcript::TranscriptMessage; +use tinyagents_session::transcript::{TranscriptMessage, TranscriptPart}; use tinyagents_harness::multimodal::{ self as mm, @@ -530,9 +530,18 @@ pub fn extract_image_placeholders_in_text(text: &str) -> Vec { /// True if any message carries an `[Image: … #att:]` sidecar placeholder. pub fn has_image_placeholders(messages: &[TranscriptMessage]) -> bool { - messages - .iter() - .any(|m| markers::text_has_image_placeholders(&m.content)) + messages.iter().any(row_has_image_placeholders) +} + +/// Whether a row's text (its `content`, or any text part of a user row with +/// typed image parts) carries a sidecar placeholder. +fn row_has_image_placeholders(row: &TranscriptMessage) -> bool { + markers::text_has_image_placeholders(&row.content) + || row.parts.as_deref().is_some_and(|parts| { + parts.iter().any(|part| { + matches!(part, TranscriptPart::Text { text } if markers::text_has_image_placeholders(text)) + }) + }) } /// Rehydrate `[Image: … #att:]` placeholders back into inline @@ -545,11 +554,22 @@ pub fn rehydrate_image_placeholders(messages: &[TranscriptMessage]) -> Vec TranscriptPart::Text { + text: markers::rehydrate_placeholders_in_text(text, &index), + }, + image => image.clone(), + }) + .collect() + }), cache_breakpoints: Vec::new(), ..m.clone() } diff --git a/crates/openhuman-core/src/agent/session_host/driver.rs b/crates/openhuman-core/src/agent/session_host/driver.rs index 1f4b031a1d1..6135a21cabe 100644 --- a/crates/openhuman-core/src/agent/session_host/driver.rs +++ b/crates/openhuman-core/src/agent/session_host/driver.rs @@ -128,7 +128,7 @@ impl SessionDriver for OpenHumanSessionDriver { .iter() .rev() .find(|message| matches!(message, Message::User(_))) - .map(Message::text) + .map(crate::agent::message_convert::user_text_with_markers) .unwrap_or_default(); let context_window = self .turn_model_source diff --git a/crates/openhuman-core/src/agent/session_host/runtime_session.rs b/crates/openhuman-core/src/agent/session_host/runtime_session.rs index 4f8817e0a33..4ffc7ee11d0 100644 --- a/crates/openhuman-core/src/agent/session_host/runtime_session.rs +++ b/crates/openhuman-core/src/agent/session_host/runtime_session.rs @@ -16,6 +16,7 @@ use tinyagents_session::transcript::TranscriptMeta; use tinyinference_llm::message::Message; use crate::agent::{ + message_convert::{user_message_from_text, user_text_with_markers}, session_host::{ driver::OpenHumanSessionDriver, OpenHumanSessionHooks, OpenHumanTranscriptCodec, }, @@ -1369,7 +1370,7 @@ impl OpenHumanSessionHost { .as_mut() .expect("runtime session initialized") .turn( - SessionTurnRequest::new(Message::user(user_message)), + SessionTurnRequest::new(user_message_from_text(user_message)), options, ) .await @@ -1605,7 +1606,7 @@ impl OpenHumanSessionHost { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .context_window = context_window; - let original_user_message = request.input.text(); + let original_user_message = user_text_with_markers(&request.input); prelude.begin_user_effects( &mut state .lock() @@ -1625,7 +1626,7 @@ impl OpenHumanSessionHost { &mut options.run_context.data, ) .await; - request.input = Message::user(enriched); + request.input = user_message_from_text(&enriched); let mut preparation = prelude .prepare(!view.resumed && view.history.is_empty()) .await @@ -1724,10 +1725,8 @@ impl OpenHumanSessionHost { .history .iter() .rev() - .find_map(|message| match message { - Message::User(_) => Some(message.text()), - _ => None, - }) + .find(|message| matches!(message, Message::User(_))) + .map(user_text_with_markers) .unwrap_or_default(); let sidecar = receipt .options diff --git a/crates/openhuman-core/src/agent/session_host/typed_transcript_compat_tests.rs b/crates/openhuman-core/src/agent/session_host/typed_transcript_compat_tests.rs index dfc8f687530..54c60629581 100644 --- a/crates/openhuman-core/src/agent/session_host/typed_transcript_compat_tests.rs +++ b/crates/openhuman-core/src/agent/session_host/typed_transcript_compat_tests.rs @@ -162,7 +162,137 @@ fn an_old_session_continued_by_this_binary_reloads_identically() { assert!(transcript .messages .iter() - .any(|row| row.content.contains("call_new"))); + .any(|row| row.tool_calls.iter().any(|call| call.id == "call_new"))); + }); +} + +/// A live turn whose text carries a ready `[IMAGE:..]` marker persists the +/// image as a typed `user_parts` row (not a text marker), a fresh process +/// resumes the same model history, and the legacy `[IMAGE:]` text rows of the +/// corpus (`image_user`) keep resuming to their golden. +#[test] +fn a_live_image_turn_persists_typed_image_parts_and_resumes_identically() { + run_async(async { + let root = tempfile::tempdir().expect("tempdir"); + let thread = thread_id("live_image"); + let mut host = build_host( + root.path(), + model( + vec![response( + vec![ContentBlock::Text("saw it".into())], + Vec::new(), + )], + true, + ), + true, + &thread, + ); + let stem = host.session_id().expect("session id"); + let png = "data:image/png;base64,iVBORw0KGgo="; + host.turn(&format!("look [IMAGE:{png}] please")) + .await + .expect("turn"); + let history = serde_json::to_value( + host.runtime_session + .as_ref() + .expect("runtime session") + .history(), + ) + .expect("history"); + drop(host); + + let path = stem_path(root.path(), &stem); + let raw = std::fs::read_to_string(&path).expect("transcript"); + let user_line = message_lines(&raw) + .into_iter() + .find(|line| line["shape"] == "user_parts") + .expect("the image turn is a typed user_parts row"); + assert!(!user_line["content"].as_str().unwrap().contains("[IMAGE:")); + assert_eq!(user_line["parts"][1]["type"], "image"); + assert_eq!(user_line["parts"][1]["url"], png); + assert!(!raw.contains("[IMAGE:"), "no text marker is persisted"); + + let mut second = build_host( + root.path(), + model(vec![ModelResponse::assistant("unused")], true), + true, + &thread, + ); + assert!(second.resume_bound_session().await.expect("resume")); + assert_eq!( + serde_json::to_value( + second + .runtime_session + .as_ref() + .expect("runtime session") + .history() + ) + .expect("history"), + history + ); + }); +} + +/// What the provider is sent for a live image turn on a vision-capable model: +/// the user message's content blocks of the request the model actually saw. +async fn live_image_request_blocks() -> Vec { + use tinyinference_llm::message::Message; + use tinyinference_llm::model::{Modalities, ModelProfile}; + + let root = tempfile::tempdir().expect("tempdir"); + let vision = std::sync::Arc::new( + tinyagents_harness::testkit::ScriptedModel::new(vec![response( + vec![ContentBlock::Text("saw it".into())], + Vec::new(), + )]) + .with_profile(ModelProfile { + tool_calling: true, + modalities: Modalities { + image_in: true, + ..Default::default() + }, + ..Default::default() + }), + ); + let mut host = build_host(root.path(), vision.clone(), true, &thread_id("vision")); + host.turn("look [IMAGE:data:image/png;base64,iVBORw0KGgo=] please") + .await + .expect("turn"); + let request = vision.requests().last().expect("request").messages.clone(); + request + .into_iter() + .rev() + .find_map(|message| match message { + Message::User(user) => Some(user.content), + _ => None, + }) + .expect("user message in the request") +} + +/// A live image turn reaches a vision-capable provider as a real image +/// content block (it used to arrive as the literal private marker text). The +/// vendor providers serialize that block for both OpenAI-compatible +/// (`image_url` part) and Anthropic (`image` source) requests. +#[test] +fn live_image_turn_request_carries_a_real_image_block() { + run_async(async { + let blocks = live_image_request_blocks().await; + assert!( + blocks.iter().any(|block| matches!( + block, + ContentBlock::Image(image) + if image.url == "data:image/png;base64,iVBORw0KGgo=" + && image.mime_type.as_deref() == Some("image/png") + )), + "{blocks:?}" + ); + assert!( + !blocks.iter().any(|block| matches!( + block, + ContentBlock::Text(text) if text.contains("OH_IMAGE") + )), + "{blocks:?}" + ); }); } diff --git a/crates/openhuman-core/src/agent/session_import/projector.rs b/crates/openhuman-core/src/agent/session_import/projector.rs index a7351da50ce..ef4ed1c23a3 100644 --- a/crates/openhuman-core/src/agent/session_import/projector.rs +++ b/crates/openhuman-core/src/agent/session_import/projector.rs @@ -98,12 +98,22 @@ fn journal_extra_metadata(message: &TranscriptMessage) -> Option]` markers for image parts, plain text otherwise. +fn journal_content(message: &TranscriptMessage) -> String { + if message.parts.is_some() { + message.display_content() + } else { + message.legacy_content() + } +} + /// The `JournalProjector` OpenHuman passes to the importer. pub fn journal_message_from_transcript(message: TranscriptMessage) -> JournalMessage { JournalMessage { id: message.id.clone(), role: message.role.clone(), - content: message.content.clone(), + content: journal_content(&message), extra_metadata: journal_extra_metadata(&message), } } diff --git a/crates/openhuman-core/src/agent/tinyagents/hosted_error.rs b/crates/openhuman-core/src/agent/tinyagents/hosted_error.rs new file mode 100644 index 00000000000..4cf6f538888 --- /dev/null +++ b/crates/openhuman-core/src/agent/tinyagents/hosted_error.rs @@ -0,0 +1,51 @@ +//! Turn-error projection of the harness's typed [`HostedError`]. +//! +//! `HostedError::message` is a fixed, sanitized string per kind, so the +//! per-model-call ceiling and an exhausted run budget look identical once the +//! error is flattened. The bound is carried structurally instead +//! (`HostedError::timeout_bound`), and this projection writes it into the turn +//! error with [`TurnTimeoutBound`] so the Sentry `timeout_bound` tag survives +//! the hosted path. The user-facing class is untouched: the text still reads as +//! `run timed out: ...`, which `web_errors` classifies as `turn_timeout`. + +use tinyagents_harness::runtime::{HostedError, HostedErrorKind, TimeoutBound}; +use tinyagents_harness::TinyAgentsError; + +use crate::agent::error::TurnTimeoutBound; + +/// The host-side name of a harness [`TimeoutBound`]. +pub(super) fn turn_timeout_bound(bound: TimeoutBound) -> TurnTimeoutBound { + match bound { + TimeoutBound::PerModelCall => TurnTimeoutBound::PerModelCall, + // `TimeoutBound` is non-exhaustive; any other bound is a run-level one. + _ => TurnTimeoutBound::RunRemaining, + } +} + +/// Convert a hosted failure into the run error the turn pipeline maps. +/// +/// Identical to `TinyAgentsError::from(error)` except that a timeout names the +/// bound that fired. It stays a terminal `Timeout`, as before. +pub(crate) fn run_error_from_hosted(error: HostedError) -> TinyAgentsError { + let bound = match (error.kind, error.timeout_bound) { + (HostedErrorKind::Timeout, Some(bound)) => Some(turn_timeout_bound(bound)), + _ => None, + }; + match bound { + Some(bound) => { + tracing::debug!( + timeout_bound = bound.tag(), + "[tinyagents] hosted turn timed out; carrying the typed bound" + ); + TinyAgentsError::Timeout(format!( + "hosted agent invocation exceeded its {}", + bound.phrase() + )) + } + None => TinyAgentsError::from(error), + } +} + +#[cfg(test)] +#[path = "hosted_error_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/agent/tinyagents/hosted_error_tests.rs b/crates/openhuman-core/src/agent/tinyagents/hosted_error_tests.rs new file mode 100644 index 00000000000..7290608dbc1 --- /dev/null +++ b/crates/openhuman-core/src/agent/tinyagents/hosted_error_tests.rs @@ -0,0 +1,41 @@ +use super::*; + +fn hosted(kind: HostedErrorKind, bound: Option) -> HostedError { + HostedError { + kind, + message: "hosted agent invocation timed out".to_string(), + timeout_bound: bound, + run: None, + } +} + +#[test] +fn a_per_call_timeout_names_the_per_model_call_bound() { + let err = run_error_from_hosted(hosted( + HostedErrorKind::Timeout, + Some(TimeoutBound::PerModelCall), + )); + assert!(matches!(err, TinyAgentsError::Timeout(_))); + let text = err.to_string(); + assert_eq!( + TurnTimeoutBound::from_message(&text), + Some(TurnTimeoutBound::PerModelCall) + ); +} + +#[test] +fn a_run_budget_timeout_names_the_run_bound() { + let text = run_error_from_hosted(hosted(HostedErrorKind::Timeout, Some(TimeoutBound::Run))) + .to_string(); + assert_eq!( + TurnTimeoutBound::from_message(&text), + Some(TurnTimeoutBound::RunRemaining) + ); +} + +#[test] +fn a_non_timeout_failure_is_converted_as_before() { + let err = run_error_from_hosted(hosted(HostedErrorKind::Provider, None)); + assert!(matches!(err, TinyAgentsError::Model(_))); + assert_eq!(TurnTimeoutBound::from_message(&err.to_string()), None); +} diff --git a/crates/openhuman-core/src/agent/tinyagents/mod.rs b/crates/openhuman-core/src/agent/tinyagents/mod.rs index 65befcbf11f..0f90481cd24 100644 --- a/crates/openhuman-core/src/agent/tinyagents/mod.rs +++ b/crates/openhuman-core/src/agent/tinyagents/mod.rs @@ -25,6 +25,7 @@ mod embeddings; mod harness_assembly; mod harness_context_ladder; mod harness_tool_registration; +pub(crate) mod hosted_error; pub mod host; pub(crate) mod journal; pub(crate) mod middleware; diff --git a/crates/openhuman-core/src/agent/tinyagents/turn_runner.rs b/crates/openhuman-core/src/agent/tinyagents/turn_runner.rs index 5c9316b1bbf..f56e9fd3350 100644 --- a/crates/openhuman-core/src/agent/tinyagents/turn_runner.rs +++ b/crates/openhuman-core/src/agent/tinyagents/turn_runner.rs @@ -659,7 +659,7 @@ async fn run_turn_via_tinyagents_inner( streaming, "[tinyagents] hosted root turn failed; keeping the typed kind" ); - tinyagents_harness::TinyAgentsError::from(error) + super::hosted_error::run_error_from_hosted(error) }) } else if streaming { let mut stream = Box::pin(harness.invoke_stream_in_context(&(), ctx, input)); diff --git a/crates/openhuman-core/src/core/runtime/subscribers.rs b/crates/openhuman-core/src/core/runtime/subscribers.rs index 152483639b6..f1d82ae6e1a 100644 --- a/crates/openhuman-core/src/core/runtime/subscribers.rs +++ b/crates/openhuman-core/src/core/runtime/subscribers.rs @@ -330,8 +330,13 @@ pub(super) fn register_domain_subscribers( // when no channel is configured — silently dropping ALL learning for // channel-less users (#5003). `agent::learning` is an Agent-family // domain; it sat on the Platform boot path only because `learning` - // used to be a top-level directory. Idempotent. - crate::agent::learning::startup::register_learning_subscribers(workspace_dir.clone()); + // used to be a top-level directory. Idempotent. The memory block + // goes along because this runs before the workspace's context is + // installed, and learning must bind the engine the user chose. + crate::agent::learning::startup::register_learning_subscribers( + workspace_dir.clone(), + config.subsystems.memory.clone(), + ); } } else { log::debug!("[event_bus] learning subscribers SKIPPED — Agent domain disabled"); diff --git a/crates/openhuman-core/src/integrations/composio/README.md b/crates/openhuman-core/src/integrations/composio/README.md index bd2287e54db..42cbc793931 100644 --- a/crates/openhuman-core/src/integrations/composio/README.md +++ b/crates/openhuman-core/src/integrations/composio/README.md @@ -52,7 +52,7 @@ triggers) from a real transport failure. | `crates/openhuman-core/src/integrations/composio/client.rs` + `client/` (`factory.rs`, `direct.rs`) | Route resolution only: `ComposioRoute` (Backend/Direct) and `resolve_composio_route` (`client/factory.rs`) keep the mode/credential decision and its user-facing errors on the host; the Composio HTTP client itself is the `tinyconnectors` module. `direct_list_connections` / `direct_list_tools` (`client/direct.rs`) are the two direct-mode v3 reads: they run the `tinyconnectors` library's `DirectRoute` in-process (it owns `limit=200`, `toolkit_versions=latest`, repeated `tags=` and the v3 reshaping) behind the host's invalid-key gate (`direct_auth`). | | `crates/openhuman-core/src/integrations/composio/module_client.rs` | The `tinyconnectors` module bridge: the one `modules`-feature `#[cfg]` switch, `methods` re-exported from `tinyconnectors_bus`, and member-failure classification (`is_unsupported_by_route`, error-prefix peeling). | | `crates/openhuman-core/src/integrations/composio/tools.rs` + `tools/` (`authorize.rs`, `connect.rs`, `list_connections.rs`, `list_toolkits.rs`, `list_tools.rs`, `execute.rs`, `registry.rs`, `visibility.rs`) | Agent tools (`ComposioListToolkitsTool`, `ComposioListConnectionsTool`, `ComposioAuthorizeTool`, `ComposioConnectTool`, `ComposioListToolsTool`, `ComposioExecuteTool`) + `all_composio_agent_tools`; scope/visibility gating (`resolve_action_scope`, `evaluate_tool_visibility`). | -| `crates/openhuman-core/src/integrations/composio/tools/direct.rs` + `tools/direct/` (`types.rs`, `construction.rs`, `transport.rs`, `http_errors.rs`) | The host `tinyconnectors::client::Transport` for the direct route (`DirectComposioClient`): carries the user's key over reqwest with the host proxy/TLS settings, HTTPS-or-loopback guard and no redirects, and renders failures with the messages users see. Not a `Tool`; the v3 paths and reshaping are `tinyconnectors::client::DirectRoute`; execution, OAuth and connection management are the module's. | +| `crates/openhuman-core/src/integrations/composio/client/{credential,network,direct}.rs` | The direct (BYO-key) reads. `credential.rs` is the key plus a validated base URL (HTTPS-or-loopback guard); `network.rs` resolves the host's `tool.composio` proxy and TLS policy for the module; `direct.rs` calls the module's stateless `ListConnectionsDirect` / `ListToolsDirect` members (the credential rides on the request, so the configured route is untouched and an unsaved key can be probed) under the host's invalid-key gate (`direct_auth`). Redirects are refused and failures are rendered with the messages users see by the module; the v3 paths and reshaping are `tinyconnectors::client::DirectRoute`. | | `crates/openhuman-core/src/integrations/composio/action_tool.rs` | `ComposioActionTool`: a `Tool` wrapping exactly one Composio action, constructed as a `ToolExposure::Deferred` tool per action of every connected toolkit (`tools/orchestrator_tools.rs`, and `agent/session_host/recorded_tools.rs` when a resumed thread rebuilds its recorded actions), reached by the orchestrator through `tool_search`. | | `crates/openhuman-core/src/integrations/composio/contract_gate.rs` | Late-bound action contract gate (#4853): on an action's first call this turn, surfaces the full live contract (via `catalog::fetch_live_toolkit_catalog`) as a recoverable tool error before executing, so the retry has the real schema in context. | | `crates/openhuman-core/src/integrations/composio/catalog.rs` + `catalog/` (`contract.rs`, `lookups.rs`, `probe.rs`) | Live Composio tool contracts (`fetch_live_toolkit_catalog`, `ToolContract`, in `catalog/contract.rs`): the unfiltered mode-aware `list_tools` schema (the module's `ListTools` member on the backend route, or direct v3 `direct_list_tools`) plus a bounded read-only response probe (`probe_tool_output_sample`, `catalog/probe.rs`) when a schema publishes no `output_parameters`. | diff --git a/crates/openhuman-core/src/integrations/composio/catalog/contract.rs b/crates/openhuman-core/src/integrations/composio/catalog/contract.rs index 1ed929b6015..24abdbee0fa 100644 --- a/crates/openhuman-core/src/integrations/composio/catalog/contract.rs +++ b/crates/openhuman-core/src/integrations/composio/catalog/contract.rs @@ -239,7 +239,7 @@ async fn fetch_raw_toolkit_tools(config: &Config, toolkit: &str) -> Option direct_list_tools(&tool, &[toolkit.to_string()], None) + ComposioRoute::Direct(tool) => direct_list_tools(config, &tool, &[toolkit.to_string()], None) .await .map_err(|e| { tracing::debug!(target: "flows", %toolkit, error = %e, "[flows] live catalog: direct fetch failed — skipping"); diff --git a/crates/openhuman-core/src/integrations/composio/client.rs b/crates/openhuman-core/src/integrations/composio/client.rs index 530c33ef1dd..430b037b0c6 100644 --- a/crates/openhuman-core/src/integrations/composio/client.rs +++ b/crates/openhuman-core/src/integrations/composio/client.rs @@ -4,19 +4,23 @@ //! reached through [`super::module_client`]. What remains here is the part the //! module does not decide for the host: which route the config selects //! ([`resolve_composio_route`]) and the two direct-mode v3 reads -//! ([`direct_list_connections`], [`direct_list_tools`]) that keep the -//! pagination and toolkit-version parameters the pinned module route omits. +//! ([`direct_list_connections`], [`direct_list_tools`]), which run in the module +//! as stateless members under the host's key-health gate, proxy and TLS policy. //! //! Logging uses the `[composio]` grep-prefix so all sidecar output for //! this domain can be filtered in one shot. +mod credential; mod direct; mod factory; +mod network; #[cfg(test)] #[path = "client_tests.rs"] mod tests; +pub use credential::DirectCredential; +pub(crate) use network::module_transport; pub use direct::direct_list_connections; pub(crate) use direct::direct_list_tools; pub(crate) use factory::create_direct_client_for_api_key; diff --git a/crates/openhuman-core/src/integrations/composio/client/credential.rs b/crates/openhuman-core/src/integrations/composio/client/credential.rs new file mode 100644 index 00000000000..ecad25f0596 --- /dev/null +++ b/crates/openhuman-core/src/integrations/composio/client/credential.rs @@ -0,0 +1,167 @@ +//! The key and base URL a direct-mode read is made with. +//! +//! Direct reads run in the `tinyconnectors` module over the bus +//! (`ListConnectionsDirect` / `ListToolsDirect`). The module owns the HTTP, the +//! paths and the response reshaping; this type is only what the host decides +//! and validates before a call: which key, and which base URL, refusing +//! anything that could send the key over plain HTTP to a remote host. + +use tinyconnectors_bus::ComposioDirectCredential; + +/// Composio's production v3 API root. The module's own default is the same +/// URL, so a credential on it sends no override. +const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3"; + +pub(super) fn ensure_https(url: &str) -> anyhow::Result<()> { + if !url.starts_with("https://") { + anyhow::bail!( + "Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https" + ); + } + Ok(()) +} + +pub(super) fn is_loopback_http_url(url: &str) -> bool { + // Parse rather than prefix-match: a raw `starts_with("http://127.0.0.1:")` + // is fooled by userinfo smuggling like + // `http://127.0.0.1:8080@evil.com/api/v3/tools`, which an HTTP client routes + // to the *parsed* host (`evil.com`). Verify the actual scheme + host and + // reject any embedded credentials so the insecure-loopback path can never + // leak the `x-api-key` header to a non-loopback host. + let Ok(parsed) = url::Url::parse(url) else { + return false; + }; + if parsed.scheme() != "http" { + return false; + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return false; + } + match parsed.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + +pub(super) fn is_loopback_http_base(url: &str) -> bool { + is_loopback_http_url(&format!("{}/", url.trim_end_matches('/'))) +} + +/// A Composio API key and the v3 base URL to use it against. +/// +/// Held by [`super::ComposioRoute::Direct`]. The key is never printed: `Debug` +/// shows the base URL only. +pub struct DirectCredential { + api_key: String, + /// `None` is Composio's production API; `Some` is a validated override (a + /// host-pinned tenant URL, or a loopback test server in debug builds). + base_v3: Option, +} + +// Manual `Debug`: never prints the key. +impl std::fmt::Debug for DirectCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DirectCredential") + .field("base_v3", &self.base_v3) + .finish_non_exhaustive() + } +} + +impl DirectCredential { + /// A credential for Composio's production API. + pub fn new(api_key: &str) -> Self { + Self::new_internal(api_key, None) + } + + pub(crate) fn auth_key_fingerprint(&self) -> u64 { + crate::integrations::composio::direct_auth::fingerprint_api_key(&self.api_key) + } + + /// Debug-test seam for raw integration coverage: a credential against + /// explicit v2/v3 base URLs. Non-HTTPS URLs are accepted only for loopback + /// hosts and only in debug builds. + /// + /// The v2 root is validated for the same safety rule but not kept: no + /// remaining request path uses it. + #[cfg(debug_assertions)] + pub fn new_with_base_urls_for_loopback( + api_key: &str, + base_v2: String, + base_v3: String, + ) -> anyhow::Result { + for base in [&base_v2, &base_v3] { + if !base.starts_with("https://") && !is_loopback_http_base(base) { + anyhow::bail!("debug Composio base URL must be HTTPS or loopback HTTP"); + } + } + Ok(Self::new_internal(api_key, Some(base_v3))) + } + + /// A credential against explicit v2/v3 API roots. Both must be HTTPS; + /// loopback HTTP is accepted only in debug builds. + pub fn new_with_base_urls( + api_key: &str, + base_v2: String, + base_v3: String, + ) -> anyhow::Result { + let allow_loopback = cfg!(debug_assertions); + for base in [&base_v2, &base_v3] { + let accepted = + base.starts_with("https://") || (allow_loopback && is_loopback_http_base(base)); + if !accepted { + anyhow::bail!("Composio base URL must be HTTPS"); + } + } + Ok(Self::new_internal(api_key, Some(base_v3))) + } + + /// Test-only seam: a credential against an explicit v3 base so a test can + /// point the module's reads at a local mock instead of `backend.composio.dev`. + /// + /// `#[cfg(test)]`-gated on purpose: an injectable base must never carry a + /// non-HTTPS URL outside tests. Production reaches the module through + /// [`Self::new`] or the validated constructors above. + #[cfg(test)] + pub(crate) fn new_with_v3_base(api_key: &str, base_v3: String) -> Self { + Self::new_internal(api_key, Some(base_v3)) + } + + fn new_internal(api_key: &str, base_v3: Option) -> Self { + let trimmed = api_key.trim(); + if trimmed.len() != api_key.len() { + // The key carried leading/trailing whitespace that would otherwise + // reach Composio's `x-api-key` header verbatim and trip the + // server-side "Invalid API key format" 401 (Sentry TAURI-RUST-D3). + // We trim here so the request succeeds; logging the length delta + // (never the key itself) helps trace which credential source + // produced a dirty value without leaking the secret. + tracing::debug!( + original_len = api_key.len(), + trimmed_len = trimmed.len(), + "[composio] trimmed leading/trailing whitespace from api_key" + ); + } + Self { + api_key: trimmed.to_string(), + base_v3, + } + } + + /// The v3 base this credential reads from. + fn effective_base(&self) -> &str { + self.base_v3.as_deref().unwrap_or(COMPOSIO_API_BASE_V3) + } + + /// What the module is handed for one read: this key, the base override if + /// there is one, and the host's proxy and TLS policy for that destination. + pub(super) fn module_credential(&self) -> ComposioDirectCredential { + ComposioDirectCredential { + api_key: self.api_key.clone(), + entity_id: None, + base_url: self.base_v3.clone(), + transport: super::network::module_transport(self.effective_base()), + } + } +} diff --git a/crates/openhuman-core/src/integrations/composio/client/direct.rs b/crates/openhuman-core/src/integrations/composio/client/direct.rs index bfa5f675e3e..fa44e12d1ed 100644 --- a/crates/openhuman-core/src/integrations/composio/client/direct.rs +++ b/crates/openhuman-core/src/integrations/composio/client/direct.rs @@ -1,22 +1,60 @@ //! Direct-mode reads: `direct_list_connections` and `direct_list_tools`. //! -//! The v3 requests and their reshaping into the canonical envelopes are the -//! connector module's `DirectRoute`, driven in-process over the host transport -//! on a bound [`crate::tools::DirectComposioClient`]. What stays here is host -//! policy: the process-wide invalid-key gate (`direct_auth`) and its -//! user-facing messages. +//! The v3 requests and their reshaping into the canonical envelopes run in the +//! connector module, reached over the bus with `ListConnectionsDirect` and +//! `ListToolsDirect`. Those members carry the credential on the request, so the +//! module's configured route is untouched and nothing is persisted (which is +//! also how `composio.set_api_key` checks a key before storing it). +//! +//! What stays here is host policy: the process-wide invalid-key gate +//! (`direct_auth`) and its user-facing messages, the host's proxy and TLS policy +//! (handed to the module with the credential, see `network`), and the identity +//! fields the host fills in itself. use std::sync::Arc; +use tinyconnectors_bus::{ComposioDirectConnectionsRequest, ComposioDirectToolsRequest}; + use super::super::direct_auth; +use super::super::module_client::{self, methods}; use super::super::types::{ComposioConnectionsResponse, ComposioToolsResponse}; +use super::DirectCredential; + +/// `ListConnectionsDirect`, with the member failure peeled down to the message +/// the user is shown. +async fn module_list_connections( + config: &crate::config::Config, + direct: &DirectCredential, +) -> anyhow::Result { + let request = ComposioDirectConnectionsRequest { + credential: direct.module_credential(), + }; + let mut response: ComposioConnectionsResponse = + module_client::call_stateless(config, methods::LIST_CONNECTIONS_DIRECT, request) + .await + .map_err(|error| { + anyhow::anyhow!(module_client::member_failure_message( + methods::LIST_CONNECTIONS_DIRECT, + &error + )) + })?; + // Identity fields are the host's to fill (`enrich_connections_with_identity` + // from cached profile data); the module also lifts them from the v3 row. + for connection in &mut response.connections { + connection.account_email = None; + connection.workspace = None; + connection.username = None; + } + Ok(response) +} /// Direct-mode connection listing (Composio v3 `/connected_accounts`), gated /// by the host's invalid-key breaker. Rows come back as canonical /// [`ComposioConnection`](super::super::types::ComposioConnection)s; a malformed /// row is kept with empty fields and reads as inactive (fail-safe). pub async fn direct_list_connections( - direct: &Arc, + config: &crate::config::Config, + direct: &Arc, ) -> anyhow::Result { tracing::debug!("[composio-direct] list_connections: GET v3 /connected_accounts"); let key_id = direct.auth_key_fingerprint(); @@ -28,7 +66,7 @@ pub async fn direct_list_connections( anyhow::bail!("{error}"); } - let response = match direct.list_connections().await { + let response = match module_list_connections(config, direct).await { Ok(response) => { direct_auth::record_direct_auth_success(key_id); response @@ -64,7 +102,7 @@ pub async fn direct_list_connections( Ok(response) } -/// Direct-mode tool listing. Calls Composio v3 `/tools` (the route sends +/// Direct-mode tool listing. Calls Composio v3 `/tools` through the module (it sends /// `limit=200`, `toolkit_versions=latest`, `toolkits=` and repeated /// `tags=`) and returns the same `ComposioToolSchema` envelope the /// backend-proxied path returns. @@ -94,7 +132,8 @@ pub async fn direct_list_connections( /// which this function never applies itself; the filter is layered on by /// its `composio_list_tools` caller, not baked in here. pub(crate) async fn direct_list_tools( - direct: &Arc, + config: &crate::config::Config, + direct: &Arc, toolkits: &[String], tags: Option<&[String]>, ) -> anyhow::Result { @@ -103,7 +142,20 @@ pub(crate) async fn direct_list_tools( tags = tags.map(<[String]>::len).unwrap_or(0), "[composio-direct] list_tools: GET v3 /tools" ); - let response = direct.list_tools(toolkits, tags.unwrap_or(&[])).await?; + let request = ComposioDirectToolsRequest { + credential: direct.module_credential(), + toolkits: toolkits.to_vec(), + tags: tags.unwrap_or(&[]).to_vec(), + }; + let response: ComposioToolsResponse = + module_client::call_stateless(config, methods::LIST_TOOLS_DIRECT, request) + .await + .map_err(|error| { + anyhow::anyhow!(module_client::member_failure_message( + methods::LIST_TOOLS_DIRECT, + &error + )) + })?; tracing::debug!( count = response.tools.len(), "[composio-direct] list_tools: mapped v3 tool schemas" diff --git a/crates/openhuman-core/src/integrations/composio/client/factory.rs b/crates/openhuman-core/src/integrations/composio/client/factory.rs index 143a385b9b7..f57d6b5cc82 100644 --- a/crates/openhuman-core/src/integrations/composio/client/factory.rs +++ b/crates/openhuman-core/src/integrations/composio/client/factory.rs @@ -9,15 +9,14 @@ //! "no connector route" the module reports when it is configured without one //! (#1710). //! -//! [`ComposioRoute::Direct`] carries the slim direct-mode HTTP reader the host -//! still needs for the two v3 reads the pinned module route does not match -//! byte-for-byte (`/connected_accounts?limit=200`, `/tools` with -//! `toolkit_versions=latest`, #3932) and for its loopback overrides. +//! [`ComposioRoute::Direct`] carries the credential (key and validated base +//! URL) the direct reads are made with, including the host-pinned and loopback +//! overrides the module's single configured route cannot express per call. use std::sync::Arc; +use super::DirectCredential; use crate::config::schema::{COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT}; -use crate::integrations::composio::tools::DirectComposioClient; // Re-declare the mode strings as local consts so they can be used as // pattern arms in the `match` below. `use` imports of `pub const &str` @@ -32,12 +31,12 @@ const MODE_DIRECT_PAT: &str = COMPOSIO_MODE_DIRECT; /// /// `Backend` is a unit: the backend-proxied route is reached through the /// connector module, whose configuration (`modules::connectors::module_config`) -/// carries the base URL and bearer. `Direct` wraps the host-side v3 reader -/// (see the module docs) for the user's own key. +/// carries the base URL and bearer. `Direct` wraps the user's own key and its +/// base URL (see the module docs). pub enum ComposioRoute { Backend, /// Held inside an `Arc` so the variant stays cheap to clone. - Direct(Arc), + Direct(Arc), } impl ComposioRoute { @@ -52,14 +51,14 @@ impl ComposioRoute { pub(crate) fn create_direct_client_for_api_key( api_key: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { direct_client(api_key, None) } fn direct_client( api_key: &str, base_urls: Option<&crate::config::ComposioDirectBaseUrls>, -) -> anyhow::Result> { +) -> anyhow::Result> { let api_key = api_key.trim(); if api_key.is_empty() { anyhow::bail!("composio direct api key must not be empty"); @@ -67,7 +66,7 @@ fn direct_client( if let Some(urls) = base_urls { let client = - DirectComposioClient::new_with_base_urls(api_key, urls.v2.clone(), urls.v3.clone())?; + DirectCredential::new_with_base_urls(api_key, urls.v2.clone(), urls.v3.clone())?; return Ok(Arc::new(client)); } #[cfg(debug_assertions)] @@ -76,15 +75,14 @@ fn direct_client( std::env::var("OPENHUMAN_COMPOSIO_DIRECT_BASE_V3").ok(), ) { (Some(base_v2), Some(base_v3)) => { - DirectComposioClient::new_with_base_urls_for_loopback(api_key, base_v2, base_v3) - .map_err(|e| { - anyhow::anyhow!("invalid debug composio direct loopback base override: {e}") - })? + DirectCredential::new_with_base_urls_for_loopback(api_key, base_v2, base_v3).map_err( + |e| anyhow::anyhow!("invalid debug composio direct loopback base override: {e}"), + )? } - _ => DirectComposioClient::new(api_key), + _ => DirectCredential::new(api_key), }; #[cfg(not(debug_assertions))] - let client = DirectComposioClient::new(api_key); + let client = DirectCredential::new(api_key); Ok(Arc::new(client)) } diff --git a/crates/openhuman-core/src/integrations/composio/client/network.rs b/crates/openhuman-core/src/integrations/composio/client/network.rs new file mode 100644 index 00000000000..0323f498238 --- /dev/null +++ b/crates/openhuman-core/src/integrations/composio/client/network.rs @@ -0,0 +1,80 @@ +//! The host's proxy and TLS policy for Composio, as the module is told it. +//! +//! The module's HTTP client cannot read the host's runtime proxy settings or +//! its TLS store, so the host resolves its policy for the `tool.composio` +//! service for one destination and hands it over: through `Configure` for the +//! configured route, and on every stateless direct read. +//! +//! The decision stays here. Whether a proxy applies at all +//! (`ProxyConfig::should_apply_to_service`: enabled, and the scope covers this +//! service), which URL serves a destination (`all_proxy` first, then the one +//! for the destination's scheme, which is the order the host's own `reqwest` +//! clients try them), and which hosts bypass it. The module only applies the +//! result. + +use tinyconnectors_bus::{ComposioTlsRoots, ComposioTransportConfig}; + +use crate::config::runtime_proxy_config; + +/// The proxy service key Composio traffic is scoped under. +const SERVICE_KEY: &str = "tool.composio"; + +fn usable(url: Option<&String>) -> Option { + let url = url?.trim(); + if url.is_empty() { + return None; + } + // The host's `reqwest` clients ignore a proxy URL they cannot parse (and + // warn); do the same instead of failing every Composio call over it. + match reqwest::Proxy::all(url) { + Ok(_) => Some(url.to_string()), + Err(error) => { + tracing::warn!(error = %error, "[composio-direct] ignoring an unusable proxy URL"); + None + } + } +} + +/// The network policy for a request to `base_url`, or `None` when it is the +/// module's default (no proxy of the host's choosing, bundled roots). +pub(crate) fn module_transport(base_url: &str) -> Option { + let proxy = runtime_proxy_config(); + let proxy_url = if proxy.should_apply_to_service(SERVICE_KEY) { + let https = base_url.trim().to_ascii_lowercase().starts_with("https://"); + usable(proxy.all_proxy.as_ref()).or_else(|| { + if https { + usable(proxy.https_proxy.as_ref()) + } else { + usable(proxy.http_proxy.as_ref()) + } + }) + } else { + None + }; + let tls_roots = if cfg!(target_os = "windows") { + ComposioTlsRoots::Platform + } else { + ComposioTlsRoots::Bundled + }; + + let config = ComposioTransportConfig { + no_proxy: if proxy_url.is_some() { + proxy.normalized_no_proxy() + } else { + Vec::new() + }, + proxy_url, + tls_roots, + }; + tracing::debug!( + proxied = config.proxy_url.is_some(), + no_proxy_entries = config.no_proxy.len(), + platform_roots = config.tls_roots == ComposioTlsRoots::Platform, + "[composio-direct] module transport policy resolved" + ); + (config != ComposioTransportConfig::default()).then_some(config) +} + +#[cfg(test)] +#[path = "network_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/integrations/composio/client/network_tests.rs b/crates/openhuman-core/src/integrations/composio/client/network_tests.rs new file mode 100644 index 00000000000..d1ad278ffd6 --- /dev/null +++ b/crates/openhuman-core/src/integrations/composio/client/network_tests.rs @@ -0,0 +1,138 @@ +//! The host's proxy policy for Composio, as handed to the module. + +use super::module_transport; +use crate::config::schema::{ProxyConfig, ProxyScope}; +use crate::config::{runtime_proxy_config, set_runtime_proxy_config}; +use tinyconnectors_bus::{ComposioTlsRoots, ComposioTransportConfig}; + +const HTTPS_BASE: &str = "https://backend.composio.dev/api/v3"; + +/// Run `body` with a runtime proxy config installed, then restore the old one. +fn with_proxy(config: ProxyConfig, body: impl FnOnce() -> T) -> T { + let _lock = crate::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let previous = runtime_proxy_config(); + set_runtime_proxy_config(config); + let result = body(); + set_runtime_proxy_config(previous); + result +} + +fn proxy(all: Option<&str>, http: Option<&str>, https: Option<&str>) -> ProxyConfig { + ProxyConfig { + enabled: true, + all_proxy: all.map(str::to_string), + http_proxy: http.map(str::to_string), + https_proxy: https.map(str::to_string), + // Scoped to Composio so that, while this process-global setting is + // installed, no other test's loopback request is proxied. + scope: ProxyScope::Services, + services: vec!["tool.composio".into()], + ..ProxyConfig::default() + } +} + +#[test] +fn no_proxy_configured_is_the_modules_default() { + let config = with_proxy(ProxyConfig::default(), || module_transport(HTTPS_BASE)); + // On Windows the platform store is always requested; elsewhere the policy + // is empty and nothing is sent at all. + if cfg!(target_os = "windows") { + assert_eq!(config.unwrap().tls_roots, ComposioTlsRoots::Platform); + } else { + assert_eq!(config, None); + } +} + +#[test] +fn an_https_destination_takes_all_proxy_first_then_https_proxy() { + let all = with_proxy( + proxy(Some("http://all:1"), Some("http://h:2"), Some("http://s:3")), + || module_transport(HTTPS_BASE), + ); + assert_eq!(all.unwrap().proxy_url.as_deref(), Some("http://all:1")); + + let https = with_proxy(proxy(None, Some("http://h:2"), Some("http://s:3")), || { + module_transport(HTTPS_BASE) + }); + assert_eq!(https.unwrap().proxy_url.as_deref(), Some("http://s:3")); + + let http_only = with_proxy(proxy(None, Some("http://h:2"), None), || { + module_transport(HTTPS_BASE) + }); + assert!(http_only.and_then(|c| c.proxy_url).is_none()); +} + +#[test] +fn a_plain_http_destination_takes_http_proxy() { + let config = with_proxy(proxy(None, Some("http://h:2"), Some("http://s:3")), || { + module_transport("http://127.0.0.1:9/api/v3") + }); + assert_eq!(config.unwrap().proxy_url.as_deref(), Some("http://h:2")); +} + +#[test] +fn no_proxy_entries_travel_with_the_proxy_only() { + let mut with = proxy(Some("http://all:1"), None, None); + with.no_proxy = vec!["localhost, .internal".into(), "10.0.0.0/8".into()]; + let config = with_proxy(with, || module_transport(HTTPS_BASE)).unwrap(); + assert_eq!( + config.no_proxy, + [".internal", "10.0.0.0/8", "localhost"], + "normalised: split, trimmed, sorted" + ); + + // No proxy URL applies, so there is nothing to exempt a host from. + let mut without = proxy(None, Some("http://h:2"), None); + without.no_proxy = vec!["localhost".into()]; + let config = with_proxy(without, || module_transport(HTTPS_BASE)); + assert!(config.map_or(true, |c| c.no_proxy.is_empty())); +} + +#[test] +fn a_scope_that_leaves_composio_out_sends_no_proxy() { + let mut scoped = proxy(Some("http://all:1"), None, None); + scoped.scope = ProxyScope::Services; + scoped.services = vec!["provider.openai".into()]; + let config = with_proxy(scoped, || module_transport(HTTPS_BASE)); + assert!(config.and_then(|c| c.proxy_url).is_none()); + + let mut covered = proxy(Some("http://all:1"), None, None); + covered.scope = ProxyScope::Services; + covered.services = vec!["tool.*".into()]; + let config = with_proxy(covered, || module_transport(HTTPS_BASE)); + assert_eq!( + config.and_then(|c| c.proxy_url).as_deref(), + Some("http://all:1") + ); + + let mut environment = proxy(Some("http://all:1"), None, None); + environment.scope = ProxyScope::Environment; + let config = with_proxy(environment, || module_transport(HTTPS_BASE)); + assert!(config.and_then(|c| c.proxy_url).is_none()); +} + +#[test] +fn an_unusable_proxy_url_is_ignored_like_the_hosts_own_clients_do() { + let config = with_proxy(proxy(Some("not a url"), None, Some("http://s:3")), || { + module_transport(HTTPS_BASE) + }); + assert_eq!( + config.and_then(|c| c.proxy_url).as_deref(), + Some("http://s:3"), + "the next usable URL is used instead" + ); + + let none = with_proxy(proxy(Some(" "), None, None), || { + module_transport(HTTPS_BASE) + }); + assert!(none.and_then(|c| c.proxy_url).is_none()); +} + +#[test] +fn the_default_value_is_never_sent() { + // Equal to the module's own default means "send nothing". + let config = with_proxy(ProxyConfig::default(), || module_transport(HTTPS_BASE)); + assert_ne!(config, Some(ComposioTransportConfig::default())); +} diff --git a/crates/openhuman-core/src/integrations/composio/client_tests.rs b/crates/openhuman-core/src/integrations/composio/client_tests.rs index 7cb4d3c91ff..9ab9873cdb4 100644 --- a/crates/openhuman-core/src/integrations/composio/client_tests.rs +++ b/crates/openhuman-core/src/integrations/composio/client_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::Config; +use crate::integrations::composio::module_client::module_guard; use axum::{extract::State, http::StatusCode, routing::get, Json, Router}; use serde_json::json; @@ -37,21 +38,29 @@ fn config_with_session_token(tmp: &tempfile::TempDir) -> Config { config } -/// Direct-mode reads are exercised over HTTP: `DirectComposioClient::new_with_v3_base` -/// points its `/tools` and `/connected_accounts` GETs at a local axum mock, so we can -/// assert the outbound `tags` filter (repeated query params) and the v3 -> -/// canonical-envelope reshape without touching `backend.composio.dev`. -fn direct_tool_for_mock(base_v3: String) -> std::sync::Arc { +/// Direct-mode reads are exercised over HTTP through the connector module: +/// `DirectCredential::new_with_v3_base` points the module's `/tools` and +/// `/connected_accounts` GETs at a local axum mock, so we can assert the +/// outbound `tags` filter (repeated query params) and the v3 -> +/// canonical-envelope reshape without touching `backend.composio.dev`. These +/// tests reach the process-global module, so they hold `module_guard`. +fn direct_tool_for_mock(base_v3: String) -> std::sync::Arc { direct_tool_for_mock_with_key(base_v3, "ck_test_direct") } fn direct_tool_for_mock_with_key( base_v3: String, api_key: &str, -) -> std::sync::Arc { - std::sync::Arc::new(crate::tools::DirectComposioClient::new_with_v3_base( - api_key, base_v3, - )) +) -> std::sync::Arc { + std::sync::Arc::new(DirectCredential::new_with_v3_base(api_key, base_v3)) +} + +/// A config that can load the connector module and names no route. +fn module_test_config(tmp: &tempfile::TempDir) -> Config { + let mut config = Config::default(); + config.config_path = tmp.path().join("config.toml"); + config.workspace_dir = tmp.path().join("workspace"); + config } struct DirectAuthFailureGuard { @@ -59,7 +68,7 @@ struct DirectAuthFailureGuard { } impl DirectAuthFailureGuard { - fn for_tool(tool: &std::sync::Arc) -> Self { + fn for_tool(tool: &std::sync::Arc) -> Self { let key_id = tool.auth_key_fingerprint(); crate::integrations::composio::direct_auth::reset_direct_auth_failure(key_id); Self { key_id } @@ -222,6 +231,9 @@ fn store_get_clear_composio_api_key_roundtrip() { #[tokio::test] async fn direct_list_connections_stops_hitting_composio_after_repeated_invalid_api_key() { + let _module = module_guard().await; + let tmp = tempfile::tempdir().unwrap(); + let config = module_test_config(&tmp); let hits = Arc::new(AtomicUsize::new(0)); let app = Router::new() .route( @@ -240,7 +252,7 @@ async fn direct_list_connections_stops_hitting_composio_after_repeated_invalid_a let _auth_guard = DirectAuthFailureGuard::for_tool(&tool); for _ in 0..2 { - let err = direct_list_connections(&tool) + let err = direct_list_connections(&config, &tool) .await .expect_err("invalid key should reject"); assert!( @@ -249,7 +261,7 @@ async fn direct_list_connections_stops_hitting_composio_after_repeated_invalid_a ); } - let opened = direct_list_connections(&tool) + let opened = direct_list_connections(&config, &tool) .await .expect_err("third invalid-key failure should open the backoff gate"); assert!( @@ -257,7 +269,7 @@ async fn direct_list_connections_stops_hitting_composio_after_repeated_invalid_a "backoff error should be actionable, got: {opened:#}" ); - let short_circuit = direct_list_connections(&tool) + let short_circuit = direct_list_connections(&config, &tool) .await .expect_err("open backoff gate should short-circuit before HTTP"); assert!( @@ -273,6 +285,9 @@ async fn direct_list_connections_stops_hitting_composio_after_repeated_invalid_a #[tokio::test] async fn direct_list_tools_forwards_tags_and_reshapes_v3_envelope() { + let _module = module_guard().await; + let tmp = tempfile::tempdir().unwrap(); + let config = module_test_config(&tmp); use axum::extract::RawQuery; use std::sync::Mutex; @@ -303,6 +318,7 @@ async fn direct_list_tools_forwards_tags_and_reshapes_v3_envelope() { let tool = direct_tool_for_mock(base); let resp = super::direct_list_tools( + &config, &tool, &["github".to_string()], Some(&["stars".to_string(), "repos".to_string()]), @@ -340,3 +356,220 @@ async fn pricing_for_config_short_circuits_in_direct_mode() { assert!(pricing.integrations.parallel.is_none()); assert!(pricing.integrations.tinyfish.is_none()); } + +// ── failure messages stay byte-identical to the pre-module client ───────── + +#[tokio::test] +async fn http_failures_keep_their_user_facing_messages() { + let _module = module_guard().await; + let tmp = tempfile::tempdir().unwrap(); + let config = module_test_config(&tmp); + let app = Router::new() + .route( + "/connected_accounts", + get(|| async { + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": {"message": "Invalid API key"}})), + ) + }), + ) + .route( + "/tools", + get(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "") }), + ); + let tool = direct_tool_for_mock_with_key( + start_mock_backend(app).await, + "ck_test_direct_message_fixture", + ); + let _auth_guard = DirectAuthFailureGuard::for_tool(&tool); + + let err = direct_list_connections(&config, &tool).await.unwrap_err(); + assert_eq!( + format!("{err:#}"), + "Composio v3 connected_accounts failed: HTTP 401: Invalid API key" + ); + let err = direct_list_tools(&config, &tool, &[], None) + .await + .unwrap_err(); + assert_eq!( + format!("{err:#}"), + "Composio v3 list_tool_schemas: HTTP 500" + ); +} + +#[tokio::test] +async fn connections_come_back_without_route_lifted_identity() { + let _module = module_guard().await; + let tmp = tempfile::tempdir().unwrap(); + let config = module_test_config(&tmp); + let app = Router::new().route( + "/connected_accounts", + get(|| async { + Json(json!({"items": [ + {"id": " ca_1 ", "toolkit": "gmail", "status": "ACTIVE", "email": "a@b.c"}, + {"id": " ", "toolkit": "slack", "status": "ACTIVE"} + ]})) + }), + ); + let tool = direct_tool_for_mock_with_key(start_mock_backend(app).await, "ck_test_identity"); + let _auth_guard = DirectAuthFailureGuard::for_tool(&tool); + let connections = direct_list_connections(&config, &tool) + .await + .unwrap() + .connections; + assert_eq!(connections.len(), 1, "blank id dropped"); + assert_eq!(connections[0].id, "ca_1"); + // Identity is the host's to enrich from cached profiles. + assert!(connections[0].account_email.is_none()); +} + +#[tokio::test] +async fn direct_reads_do_not_forward_credentials_across_redirects() { + let _module = module_guard().await; + let tmp = tempfile::tempdir().unwrap(); + let config = module_test_config(&tmp); + let redirected_request_seen = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed = redirected_request_seen.clone(); + let destination = start_mock_backend(Router::new().route( + "/tools", + get(move || { + let observed = observed.clone(); + async move { + observed.store(true, Ordering::SeqCst); + Json(json!({"items": []})) + } + }), + )) + .await; + let redirect = format!("{destination}/tools"); + let source = start_mock_backend(Router::new().route( + "/tools", + get(move || { + let redirect = redirect.clone(); + async move { axum::response::Redirect::temporary(&redirect) } + }), + )) + .await; + + let tool = direct_tool_for_mock_with_key(source, "ck_secret_value"); + assert!(direct_list_tools(&config, &tool, &[], None).await.is_err()); + assert!(!redirected_request_seen.load(Ordering::SeqCst)); +} + +// ── the host's proxy policy reaches the module ──────────────────────────── + +/// A CONNECT proxy on loopback that tunnels to whatever it is asked for and +/// reports each CONNECT request line it saw. +fn connect_proxy() -> (String, std::sync::mpsc::Receiver) { + use std::io::{BufRead, BufReader, Write}; + use std::net::{TcpListener, TcpStream}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut client) = stream else { return }; + let mut reader = BufReader::new(client.try_clone().unwrap()); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + continue; + } + loop { + let mut header = String::new(); + if reader.read_line(&mut header).unwrap_or(0) == 0 || header == "\r\n" { + break; + } + } + let _ = sender.send(line.clone()); + let Some(target) = line + .strip_prefix("CONNECT ") + .and_then(|rest| rest.split(' ').next()) + else { + continue; + }; + let Ok(upstream) = TcpStream::connect(target) else { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n"); + continue; + }; + let _ = client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n"); + let (mut client_read, mut upstream_write) = + (client.try_clone().unwrap(), upstream.try_clone().unwrap()); + std::thread::spawn(move || { + let _ = std::io::copy(&mut client_read, &mut upstream_write); + }); + let mut upstream_read = upstream; + let _ = std::io::copy(&mut upstream_read, &mut client); + } + }); + (format!("http://127.0.0.1:{port}"), receiver) +} + +#[tokio::test] +async fn direct_reads_go_through_the_hosts_runtime_proxy() { + use crate::config::schema::{ProxyConfig, ProxyScope}; + use crate::config::{runtime_proxy_config, set_runtime_proxy_config}; + + let _module = module_guard().await; + let _env = crate::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let config = module_test_config(&tmp); + let (proxy, proxied) = connect_proxy(); + let hits = Arc::new(AtomicUsize::new(0)); + let app = Router::new() + .route( + "/connected_accounts", + get(|State(hits): State>| async move { + hits.fetch_add(1, Ordering::SeqCst); + Json(json!({"items": [{"id": "ca_p", "toolkit": "gmail", "status": "ACTIVE"}]})) + }), + ) + .with_state(hits.clone()); + let base = start_mock_backend(app).await; + let tool = direct_tool_for_mock_with_key(base.clone(), "ck_test_proxy"); + let _auth_guard = DirectAuthFailureGuard::for_tool(&tool); + + let previous = runtime_proxy_config(); + set_runtime_proxy_config(ProxyConfig { + enabled: true, + http_proxy: Some(proxy.clone()), + // Only Composio's traffic: the runtime proxy is process-global, and + // every other test's loopback requests must stay direct meanwhile. + scope: ProxyScope::Services, + services: vec!["tool.composio".into()], + ..ProxyConfig::default() + }); + let through = direct_list_connections(&config, &tool).await; + // A destination on the no-proxy list is called directly even with a proxy. + set_runtime_proxy_config(ProxyConfig { + enabled: true, + http_proxy: Some(proxy), + no_proxy: vec!["127.0.0.1".into()], + scope: ProxyScope::Services, + services: vec!["tool.composio".into()], + ..ProxyConfig::default() + }); + let bypassed_proxied_before = proxied.try_iter().count(); + let bypassed = direct_list_connections(&config, &tool).await; + let bypassed_proxied = proxied.try_iter().count(); + set_runtime_proxy_config(previous); + + assert_eq!(through.unwrap().connections[0].id, "ca_p"); + assert_eq!( + bypassed_proxied_before, 1, + "the first read must have been tunnelled by the proxy" + ); + assert!(bypassed.is_ok()); + assert_eq!( + bypassed_proxied, 0, + "a no_proxy destination skips the proxy" + ); + assert_eq!( + hits.load(Ordering::SeqCst), + 2, + "both reads reached Composio" + ); +} diff --git a/crates/openhuman-core/src/integrations/composio/connected_integrations/fetch_uncached.rs b/crates/openhuman-core/src/integrations/composio/connected_integrations/fetch_uncached.rs index 33de168d1f5..cea268626cb 100644 --- a/crates/openhuman-core/src/integrations/composio/connected_integrations/fetch_uncached.rs +++ b/crates/openhuman-core/src/integrations/composio/connected_integrations/fetch_uncached.rs @@ -203,7 +203,7 @@ pub(super) async fn fetch_connected_integrations_uncached( // factory) still routes to the user's tenant. Direct-only // users without a backend session get empty tools — that // matches `composio_list_tools`'s direct-mode policy. - let connections = match direct_list_connections(direct).await { + let connections = match direct_list_connections(config, direct).await { Ok(resp) => resp.connections, Err(e) => { tracing::warn!( diff --git a/crates/openhuman-core/src/integrations/composio/module_client.rs b/crates/openhuman-core/src/integrations/composio/module_client.rs index 17cf99261cd..1ad9be6f31c 100644 --- a/crates/openhuman-core/src/integrations/composio/module_client.rs +++ b/crates/openhuman-core/src/integrations/composio/module_client.rs @@ -188,6 +188,70 @@ pub async fn reconcile_route_if_loaded(_config: &crate::config::Config) -> Resul Ok(()) } +/// Call a member that carries its own credential and leaves the configured +/// route alone (the stateless direct reads). +/// +/// # Errors +/// +/// As [`call`]. +#[cfg(feature = "modules")] +pub async fn call_stateless( + config: &crate::config::Config, + member: &str, + request: Request, +) -> Result +where + Request: serde::Serialize + Send, + Reply: serde::de::DeserializeOwned, +{ + crate::modules::connectors::call_stateless(config, member, request).await +} + +/// Call a stateless member. Always fails without the `modules` feature. +/// +/// # Errors +/// +/// Always, explaining that this build has no module loader. +#[cfg(not(feature = "modules"))] +pub async fn call_stateless( + _config: &crate::config::Config, + member: &str, + _request: Request, +) -> Result +where + Request: serde::Serialize + Send, + Reply: serde::de::DeserializeOwned, +{ + Err(format!("{member}: {WITHOUT_MODULES}")) +} + +/// The message a stateless member's failure carries, with the transport layers +/// peeled off. +/// +/// The bus renders a member failure as `: ai.tinyhumans.tinybus.Error.: `. +/// For the direct reads `` is the text users have always been shown +/// (`Composio v3 connected_accounts failed: HTTP 401: Invalid API key`), and the +/// host's invalid-key gate and failure classification key on it, so it must come +/// back exactly. Anything not shaped like a member failure (the module could +/// not be loaded or reached) is returned unchanged. +#[must_use] +pub fn member_failure_message(member: &str, error: &str) -> String { + const WIRE_ERROR_PREFIX: &str = "ai.tinyhumans.tinybus.Error."; + let Some(mut message) = error + .strip_prefix(member) + .and_then(|rest| rest.strip_prefix(": ")) + else { + return error.to_string(); + }; + while let Some(tail) = message + .strip_prefix(WIRE_ERROR_PREFIX) + .and_then(|rest| rest.split_once(": ").map(|(_, tail)| tail)) + { + message = tail; + } + message.to_string() +} + /// Call a member that takes no arguments. /// /// # Errors diff --git a/crates/openhuman-core/src/integrations/composio/module_client_tests.rs b/crates/openhuman-core/src/integrations/composio/module_client_tests.rs index b14927c0020..d54f4d7215f 100644 --- a/crates/openhuman-core/src/integrations/composio/module_client_tests.rs +++ b/crates/openhuman-core/src/integrations/composio/module_client_tests.rs @@ -1,6 +1,6 @@ //! Tests for the connector module shim. -use super::{is_unsupported_by_route, methods}; +use super::{is_unsupported_by_route, member_failure_message, methods}; #[test] fn classified_errors_start_with_the_frontend_marker() { @@ -122,3 +122,44 @@ async fn a_build_without_the_module_loader_says_so() { assert!(error.contains("modules"), "{error}"); assert!(error.contains(methods::LIST_TOOLKITS), "{error}"); } + +// ── stateless member failures ───────────────────────────────────────────── + +#[test] +fn a_stateless_member_failure_is_peeled_to_the_message_the_user_reads() { + let wrapped = "ListConnectionsDirect: ai.tinyhumans.tinybus.Error.Failed: \ + Composio v3 connected_accounts failed: HTTP 401: Invalid API key"; + assert_eq!( + member_failure_message("ListConnectionsDirect", wrapped), + "Composio v3 connected_accounts failed: HTTP 401: Invalid API key" + ); + // Every wire layer present is peeled, however many there are. + let doubled = "ListToolsDirect: ai.tinyhumans.tinybus.Error.Failed: \ + ai.tinyhumans.tinybus.Error.Failed: Composio v3 list_tool_schemas: HTTP 500"; + assert_eq!( + member_failure_message("ListToolsDirect", doubled), + "Composio v3 list_tool_schemas: HTTP 500" + ); + // A message that merely contains a colon is not cut. + let plain = "ListToolsDirect: ai.tinyhumans.tinybus.Error.Failed: error sending request: boom"; + assert_eq!( + member_failure_message("ListToolsDirect", plain), + "error sending request: boom" + ); +} + +#[test] +fn a_failure_that_is_not_a_member_failure_is_left_alone() { + // The module could not be loaded or reached: there is no member prefix. + let unloaded = "the module runtime is unavailable: boom"; + assert_eq!( + member_failure_message("ListConnectionsDirect", unloaded), + unloaded + ); + // Another member's failure is not ours to rewrite. + let other = "Execute: ai.tinyhumans.tinybus.Error.Failed: x"; + assert_eq!( + member_failure_message("ListConnectionsDirect", other), + other + ); +} diff --git a/crates/openhuman-core/src/integrations/composio/ops/connections.rs b/crates/openhuman-core/src/integrations/composio/ops/connections.rs index f57729b24a1..6e346be940c 100644 --- a/crates/openhuman-core/src/integrations/composio/ops/connections.rs +++ b/crates/openhuman-core/src/integrations/composio/ops/connections.rs @@ -76,10 +76,12 @@ pub async fn composio_list_connections( else { unreachable!("direct Composio mode must construct a direct client") }; - direct_list_connections(&direct).await.map_err(|error| { - report_composio_op_error("list_connections", &error); - format!("[composio-direct] list_connections: {error:#}") - })? + direct_list_connections(config, &direct) + .await + .map_err(|error| { + report_composio_op_error("list_connections", &error); + format!("[composio-direct] list_connections: {error:#}") + })? } else { connectors::call_bare::(config, methods::LIST_CONNECTIONS) .await diff --git a/crates/openhuman-core/src/integrations/composio/ops/direct_mode.rs b/crates/openhuman-core/src/integrations/composio/ops/direct_mode.rs index d2473aef64c..88d75a02f26 100644 --- a/crates/openhuman-core/src/integrations/composio/ops/direct_mode.rs +++ b/crates/openhuman-core/src/integrations/composio/ops/direct_mode.rs @@ -9,14 +9,14 @@ use super::super::{ }; use super::error_utils::OpResult; -async fn validate_direct_api_key_before_store(api_key: &str) -> OpResult<()> { +async fn validate_direct_api_key_before_store(config: &Config, api_key: &str) -> OpResult<()> { let key_id = direct_auth::fingerprint_api_key(api_key); direct_auth::reset_direct_auth_failure(key_id); let direct = create_direct_client_for_api_key(api_key) .map_err(|e| format!("[composio-direct] validate_api_key: {e}"))?; - match direct_list_connections(&direct).await { + match direct_list_connections(config, &direct).await { Ok(_) => { direct_auth::record_direct_auth_success(key_id); tracing::debug!("[composio-direct] validate_api_key: probe succeeded"); @@ -85,7 +85,7 @@ pub async fn composio_set_api_key( activate_direct, "[composio-direct] set_api_key (redacted)" ); - validate_direct_api_key_before_store(trimmed).await?; + validate_direct_api_key_before_store(config, trimmed).await?; crate::security::credentials::store_composio_api_key(config, trimmed) .await diff --git a/crates/openhuman-core/src/integrations/composio/tools.rs b/crates/openhuman-core/src/integrations/composio/tools.rs index dbd0331ca0e..f4661eb6dfd 100644 --- a/crates/openhuman-core/src/integrations/composio/tools.rs +++ b/crates/openhuman-core/src/integrations/composio/tools.rs @@ -24,8 +24,6 @@ //! the right slug and supply valid arguments without a separate round //! trip. -mod direct; - mod authorize; mod connect; mod execute; @@ -43,7 +41,6 @@ mod visibility; #[path = "tools_tests.rs"] mod tests; -pub use direct::DirectComposioClient; pub use execute::ComposioExecuteTool; pub(crate) use live_config::live_composio_config; pub(crate) use redact::redact_composio_outcome; diff --git a/crates/openhuman-core/src/integrations/composio/tools/connect.rs b/crates/openhuman-core/src/integrations/composio/tools/connect.rs index 75f6a9a8ffe..0214186a688 100644 --- a/crates/openhuman-core/src/integrations/composio/tools/connect.rs +++ b/crates/openhuman-core/src/integrations/composio/tools/connect.rs @@ -109,7 +109,7 @@ pub(super) async fn connection_is_active(config: &Config, toolkit: &str) -> anyh Ok(active_match(&resp.connections)) } ComposioRoute::Direct(direct) => Ok(active_match( - &direct_list_connections(&direct).await?.connections, + &direct_list_connections(config, &direct).await?.connections, )), } } diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct.rs b/crates/openhuman-core/src/integrations/composio/tools/direct.rs deleted file mode 100644 index a61acc72fbe..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct.rs +++ /dev/null @@ -1,27 +0,0 @@ -// Host-side HTTP transport for Composio's own v3 API with the user's key. -// -// What Composio's direct route sends and how it reshapes the answer is -// `tinyconnectors::client::DirectRoute` (paths, `limit=200`, -// `toolkit_versions=latest` (#3932), repeated `tags=`, blank-id filtering, -// v3-to-envelope translation). This module is only the seam the route needs -// from the host: a `Transport` that carries the key, refuses non-HTTPS bases, -// never follows redirects, and goes out through the host's proxy and TLS -// settings, plus the pre-store API-key probe's client. - -#[cfg(test)] -#[path = "direct_tests.rs"] -mod tests; - -mod construction; -mod http_errors; -mod transport; -mod types; - -pub use types::DirectComposioClient; - -// Test-only bridges: the flat `direct_tests.rs` module still expects these -// internal helpers to be reachable unqualified via `use super::*`. -#[cfg(test)] -use http_errors::{extract_api_error_message, sanitize_error_message}; -#[cfg(test)] -use types::{ensure_https, is_loopback_http_url, COMPOSIO_API_BASE_V3}; diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct/construction.rs b/crates/openhuman-core/src/integrations/composio/tools/direct/construction.rs deleted file mode 100644 index d310c8c585a..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct/construction.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Constructors and the small per-request setup helpers (`client`, -//! `ensure_request_url`) that every other `DirectComposioClient` impl block -//! relies on. - -use super::types::{ - ensure_https, is_loopback_http_base, is_loopback_http_url, DirectComposioClient, - COMPOSIO_API_BASE_V3, -}; -use reqwest::Client; - -impl DirectComposioClient { - pub fn new(api_key: &str) -> Self { - // Production always pins the real HTTPS endpoint. - Self::new_internal(api_key, COMPOSIO_API_BASE_V3.to_string(), false) - } - - pub(crate) fn auth_key_fingerprint(&self) -> u64 { - crate::integrations::composio::direct_auth::fingerprint_api_key(&self.api_key) - } - - /// Debug-test seam for raw integration coverage: construct a direct - /// Composio client against explicit v2/v3 base URLs. Non-HTTPS URLs are - /// accepted only for loopback hosts and only in debug builds. - /// - /// The v2 root is validated for the same safety rule but not stored: no - /// remaining request path uses it. - #[cfg(debug_assertions)] - pub fn new_with_base_urls_for_loopback( - api_key: &str, - base_v2: String, - base_v3: String, - ) -> anyhow::Result { - for base in [&base_v2, &base_v3] { - if !base.starts_with("https://") && !is_loopback_http_base(base) { - anyhow::bail!("debug Composio base URL must be HTTPS or loopback HTTP"); - } - } - Ok(Self::new_internal(api_key, base_v3, true)) - } - - /// Construct against explicit v2/v3 API roots. Both must be HTTPS; - /// loopback HTTP is accepted only in debug builds. - pub fn new_with_base_urls( - api_key: &str, - base_v2: String, - base_v3: String, - ) -> anyhow::Result { - let allow_loopback = cfg!(debug_assertions); - for base in [&base_v2, &base_v3] { - let accepted = - base.starts_with("https://") || (allow_loopback && is_loopback_http_base(base)); - if !accepted { - anyhow::bail!("Composio base URL must be HTTPS"); - } - } - Ok(Self::new_internal(api_key, base_v3, allow_loopback)) - } - - /// Test-only seam: construct with an explicit Composio v3 base URL so - /// unit tests can point the direct `/tools` request — including the - /// `tags` filter — at a local mock instead of `backend.composio.dev`. - /// - /// `#[cfg(test)]`-gated on purpose: the transport attaches the - /// `x-api-key` header to whatever `base_v3` holds, so the only way to - /// reach the v3 endpoint in production is [`Self::new`], which always - /// uses the HTTPS [`COMPOSIO_API_BASE_V3`] const. An injectable base must - /// never carry a non-HTTPS URL outside tests. - #[cfg(test)] - pub(crate) fn new_with_v3_base(api_key: &str, base_v3: String) -> Self { - Self::new_internal(api_key, base_v3, true) - } - - /// Shared constructor body. Private so the injectable `base_v3` cannot be - /// supplied by production callers — they go through [`Self::new`] (real - /// HTTPS const) and tests through the `#[cfg(test)]` `new_with_v3_base`. - fn new_internal(api_key: &str, base_v3: String, allow_insecure_loopback: bool) -> Self { - let trimmed = api_key.trim(); - if trimmed.len() != api_key.len() { - // The key carried leading/trailing whitespace that would otherwise - // reach Composio's `x-api-key` header verbatim and trip the - // server-side "Invalid API key format" 401 (Sentry TAURI-RUST-D3). - // We trim here so the request succeeds; logging the length delta - // (never the key itself) helps trace which credential source - // produced a dirty value without leaking the secret. - tracing::debug!( - original_len = api_key.len(), - trimmed_len = trimmed.len(), - "[composio] trimmed leading/trailing whitespace from api_key" - ); - } - Self { - api_key: trimmed.to_string(), - base_v3, - allow_insecure_loopback, - } - } - - pub(super) fn client(&self) -> Client { - let builder = || { - crate::config::apply_runtime_proxy_to_builder( - crate::util::tls::tls_client_builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(60)) - .connect_timeout(std::time::Duration::from_secs(10)), - "tool.composio", - ) - }; - builder().build().unwrap_or_else(|error| { - tracing::warn!( - service_key = "tool.composio", - "Failed to build proxied Composio client: {error}" - ); - crate::util::tls::tls_client_builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(60)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_default() - }) - } - - pub(super) fn ensure_request_url(&self, url: &str) -> anyhow::Result<()> { - if self.allow_insecure_loopback && is_loopback_http_url(url) { - return Ok(()); - } - ensure_https(url) - } -} diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct/http_errors.rs b/crates/openhuman-core/src/integrations/composio/tools/direct/http_errors.rs deleted file mode 100644 index fb0e9352501..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct/http_errors.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Response-parsing helper shared by every direct Composio HTTP call path: -//! turning a failed `reqwest::Response` into a redacted error string. - -pub(super) async fn response_error(resp: reqwest::Response) -> String { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if body.trim().is_empty() { - return format!("HTTP {}", status.as_u16()); - } - - if let Some(api_error) = extract_api_error_message(&body) { - return format!( - "HTTP {}: {}", - status.as_u16(), - sanitize_error_message(&api_error) - ); - } - - format!("HTTP {}", status.as_u16()) -} - -pub(super) fn sanitize_error_message(message: &str) -> String { - let mut sanitized = message.replace('\n', " "); - for marker in [ - "connected_account_id", - "connectedAccountId", - "entity_id", - "entityId", - "user_id", - "userId", - ] { - sanitized = sanitized.replace(marker, "[redacted]"); - } - - crate::util::truncate_with_ellipsis(&sanitized, 240) -} - -pub(super) fn extract_api_error_message(body: &str) -> Option { - let parsed: serde_json::Value = serde_json::from_str(body).ok()?; - parsed - .get("error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .map(ToString::to_string) - .or_else(|| { - parsed - .get("message") - .and_then(|v| v.as_str()) - .map(ToString::to_string) - }) -} diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct/transport.rs b/crates/openhuman-core/src/integrations/composio/tools/direct/transport.rs deleted file mode 100644 index 4e350f49873..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct/transport.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! The host's [`tinyconnectors::client::Transport`] for the direct route. -//! -//! `DirectRoute` owns the paths and the response translation; this owns the -//! wire. It exists (instead of the module's own `ureq` transport) because the -//! host must keep honouring `[proxy]` runtime settings and its TLS roots for -//! Composio traffic, refuse redirects so the `x-api-key` header cannot follow -//! one, and render failures with the exact messages users and the -//! observability classifier already key on. - -use std::sync::Arc; - -use async_trait::async_trait; -use tinyconnectors::client::{DirectRoute, Route, Transport}; -use tinyconnectors::Error as ConnectorError; - -use super::http_errors::response_error; -use super::types::DirectComposioClient; -use crate::integrations::composio::types::{ComposioConnectionsResponse, ComposioToolsResponse}; - -/// Failure label per request path, matching the messages the host has always -/// produced (and the `[composio-direct]` observability anchors match on). -fn failure_label(path: &str) -> &'static str { - if path.starts_with("/connected_accounts") { - "Composio v3 connected_accounts failed" - } else { - "Composio v3 list_tool_schemas" - } -} - -fn decode_label(path: &str) -> &'static str { - if path.starts_with("/connected_accounts") { - "Failed to decode Composio v3 connected_accounts response" - } else { - "Failed to decode Composio v3 tools response" - } -} - -fn transport_error(path: &str, message: String) -> ConnectorError { - ConnectorError::Transport { - path: path.to_string(), - message, - } -} - -impl DirectComposioClient { - /// The module's direct route over this client's transport. - /// - /// Built per call: the host's process-wide key gate (`direct_auth`) is what - /// persists across calls, so the route's own per-instance gate never trips - /// here by design. - pub(crate) fn route(self: &Arc) -> DirectRoute { - DirectRoute::new(self.clone(), &self.api_key, "default") - } -} - -/// Collapse a route failure into the message the transport produced. -/// -/// The route wraps transport failures as `request to failed: `; -/// users have only ever seen ``. -fn route_error(error: ConnectorError) -> anyhow::Error { - match error { - ConnectorError::Transport { message, .. } => anyhow::anyhow!(message), - other => anyhow::anyhow!(other.to_string()), - } -} - -impl DirectComposioClient { - /// `GET /connected_accounts` through the module's direct route, reshaped - /// into canonical connections. - pub(crate) async fn list_connections( - self: &Arc, - ) -> anyhow::Result { - let mut response = self.route().list_connections().await.map_err(route_error)?; - // Identity fields are the host's to fill (`enrich_connections_with_identity` - // from cached profile data); the route also lifts them from the v3 row. - for connection in &mut response.connections { - connection.account_email = None; - connection.workspace = None; - connection.username = None; - } - Ok(response) - } - - /// `GET /tools` through the module's direct route. - pub(crate) async fn list_tools( - self: &Arc, - toolkits: &[String], - tags: &[String], - ) -> anyhow::Result { - self.route() - .list_tools(toolkits, tags) - .await - .map_err(route_error) - } -} - -#[async_trait] -impl Transport for DirectComposioClient { - async fn get(&self, path: &str) -> tinyconnectors::Result { - let url = format!("{}{}", self.base_v3, path); - self.ensure_request_url(&url) - .map_err(|e| transport_error(path, format!("{e:#}")))?; - tracing::debug!(path = %path.split('?').next().unwrap_or(path), "[composio-direct] transport GET"); - - let resp = self - .client() - .get(&url) - .header("x-api-key", &self.api_key) - .send() - .await - .map_err(|e| transport_error(path, format!("{:#}", anyhow::Error::from(e))))?; - - if !resp.status().is_success() { - let err = response_error(resp).await; - return Err(transport_error( - path, - format!("{}: {err}", failure_label(path)), - )); - } - - resp.json::().await.map_err(|e| { - transport_error( - path, - format!("{}: {:#}", decode_label(path), anyhow::Error::from(e)), - ) - }) - } - - async fn post( - &self, - path: &str, - _body: &serde_json::Value, - ) -> tinyconnectors::Result { - Err(transport_error( - path, - "the host direct transport serves reads only; writes run in the connector module" - .to_string(), - )) - } - - async fn delete(&self, path: &str) -> tinyconnectors::Result { - Err(transport_error( - path, - "the host direct transport serves reads only; writes run in the connector module" - .to_string(), - )) - } -} diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct/types.rs b/crates/openhuman-core/src/integrations/composio/tools/direct/types.rs deleted file mode 100644 index f43f75107bf..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct/types.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Core `DirectComposioClient` struct and the loopback/HTTPS URL-safety -//! helpers shared by every direct-mode Composio request path. - -pub(super) const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3"; - -pub(super) fn ensure_https(url: &str) -> anyhow::Result<()> { - if !url.starts_with("https://") { - anyhow::bail!( - "Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https" - ); - } - Ok(()) -} - -pub(super) fn is_loopback_http_url(url: &str) -> bool { - // Parse rather than prefix-match: a raw `starts_with("http://127.0.0.1:")` - // is fooled by userinfo smuggling like - // `http://127.0.0.1:8080@evil.com/api/v3/tools`, which reqwest routes to the - // *parsed* host (`evil.com`). Verify the actual scheme + host and reject any - // embedded credentials so the insecure-loopback path can never leak the - // `x-api-key` header to a non-loopback host. - let Ok(parsed) = url::Url::parse(url) else { - return false; - }; - if parsed.scheme() != "http" { - return false; - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return false; - } - match parsed.host() { - Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), - Some(url::Host::Ipv4(ip)) => ip.is_loopback(), - Some(url::Host::Ipv6(ip)) => ip.is_loopback(), - None => false, - } -} - -pub(super) fn is_loopback_http_base(url: &str) -> bool { - is_loopback_http_url(&format!("{}/", url.trim_end_matches('/'))) -} - -/// Host-side HTTP transport for Composio's own v3 API with the user's key. -/// -/// Composio execution, the OAuth handoff and connection management all run in -/// the `tinyconnectors` module. This client carries the direct-route reads -/// (`tinyconnectors::client::DirectRoute` over the `Transport` impl in -/// `transport.rs`) and the pre-store API-key probe through the host's proxy and -/// TLS settings. -pub struct DirectComposioClient { - pub(super) api_key: String, - /// Base URL for Composio v3 endpoints (`{base}/tools`). Production - /// always uses [`COMPOSIO_API_BASE_V3`] via [`DirectComposioClient::new`]; - /// the `#[cfg(test)]` `new_with_v3_base` constructor lets unit tests point - /// the direct-mode `/tools` listing at a local axum mock. - pub(super) base_v3: String, - pub(super) allow_insecure_loopback: bool, -} - -// Manual `Debug` (a `Transport` must be `Debug`): never prints the key. -impl std::fmt::Debug for DirectComposioClient { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DirectComposioClient") - .field("base_v3", &self.base_v3) - .finish_non_exhaustive() - } -} diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct_tests.rs b/crates/openhuman-core/src/integrations/composio/tools/direct_tests.rs deleted file mode 100644 index 1ee2a74e89f..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct_tests.rs +++ /dev/null @@ -1,20 +0,0 @@ -use super::*; - -use serde_json::json; - -/// Spawn a throwaway axum mock bound to an ephemeral port and return its base -/// URL. Mirrors `start_mock_backend` in `client_tests.rs` so both HTTP-level -/// direct-mode tests share one setup model. -async fn start_mock_backend(app: axum::Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - format!("http://127.0.0.1:{}", addr.port()) -} - -#[path = "direct_tests_constructor_and_wire_types_tests.rs"] -mod constructor_and_wire_types_tests; -#[path = "direct_tests_sanitization_and_connected_accounts_tests.rs"] -mod sanitization_and_connected_accounts_tests; diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct_tests_constructor_and_wire_types_tests.rs b/crates/openhuman-core/src/integrations/composio/tools/direct_tests_constructor_and_wire_types_tests.rs deleted file mode 100644 index ea91be47030..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct_tests_constructor_and_wire_types_tests.rs +++ /dev/null @@ -1,238 +0,0 @@ -use super::*; - -use std::sync::Arc; -// ── Constructor ─────────────────────────────────────────── - -#[tokio::test] -async fn direct_client_does_not_forward_credentials_across_redirects() { - let redirected_request_seen = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let observed = redirected_request_seen.clone(); - let destination = start_mock_backend(axum::Router::new().route( - "/tools", - axum::routing::get(move || { - let observed = observed.clone(); - async move { - observed.store(true, std::sync::atomic::Ordering::SeqCst); - axum::Json(json!({"items": []})) - } - }), - )) - .await; - let redirect = format!("{destination}/tools"); - let source = start_mock_backend(axum::Router::new().route( - "/tools", - axum::routing::get(move || { - let redirect = redirect.clone(); - async move { axum::response::Redirect::temporary(&redirect) } - }), - )) - .await; - - let tool = Arc::new(DirectComposioClient::new_with_v3_base( - "ck_secret_value", - source, - )); - assert!(tool.list_tools(&[], &[]).await.is_err()); - assert!(!redirected_request_seen.load(std::sync::atomic::Ordering::SeqCst)); -} - -// ── Execute validation ──────────────────────────────────── - -// ── API response parsing ────────────────────────────────── - -#[test] -fn extract_api_error_message_from_common_shapes() { - let nested = r#"{"error":{"message":"tool not found"}}"#; - let flat = r#"{"message":"invalid api key"}"#; - - assert_eq!( - extract_api_error_message(nested).as_deref(), - Some("tool not found") - ); - assert_eq!( - extract_api_error_message(flat).as_deref(), - Some("invalid api key") - ); - assert_eq!(extract_api_error_message("not-json"), None); -} - -#[test] -fn composio_api_base_url_is_v3() { - assert_eq!(COMPOSIO_API_BASE_V3, "https://backend.composio.dev/api/v3"); -} - -// ── list_tool_schemas_v3 query builder (direct-mode tags) ────────────────── - -// ── direct list_tools over HTTP (direct-mode tags reach the wire) ─────── - -#[tokio::test] -async fn list_tool_schemas_v3_sends_repeated_tags_to_v3_tools_endpoint() { - use axum::{extract::RawQuery, routing::get, Json, Router}; - use std::sync::Mutex; - - // Capture the raw query string the server sees. `RawQuery` (not - // `Query`) is required because a HashMap would collapse the - // repeated `tags=` params we specifically need to assert on. - let captured: Arc>> = Arc::new(Mutex::new(None)); - let sink = captured.clone(); - let app = Router::new().route( - "/tools", - get(move |RawQuery(q): RawQuery| { - let sink = sink.clone(); - async move { - *sink.lock().unwrap() = q; - Json(json!({ - "items": [{ - "slug": "GITHUB_STAR_A_REPOSITORY", - "description": "Star a repository", - "input_parameters": { "type": "object" }, - "toolkit": { "slug": "github" } - }] - })) - } - }), - ); - let base = start_mock_backend(app).await; - - let tool = Arc::new(DirectComposioClient::new_with_v3_base( - "ck_test_direct", - base, - )); - let items = tool - .list_tools( - &["github".to_string()], - &["stars".to_string(), "repos".to_string()], - ) - .await - .expect("direct v3 /tools should succeed against the mock") - .tools; - - let query = captured - .lock() - .unwrap() - .clone() - .expect("mock server should have observed a query string"); - - // tags must be REPEATED params (tags=stars&tags=repos) — the Composio v3 - // contract — NOT the comma-joined form the backend proxy uses. - assert!(query.contains("tags=stars"), "query was: {query}"); - assert!(query.contains("tags=repos"), "query was: {query}"); - assert!( - !query.contains("stars%2Crepos") && !query.contains("stars,repos"), - "tags must not be comma-joined; query was: {query}" - ); - assert!(query.contains("toolkits=github"), "query was: {query}"); - assert!(query.contains("limit=200"), "query was: {query}"); - // #3932: post-launch toolkits are invisible without toolkit_versions=latest. - assert!( - query.contains("toolkit_versions=latest"), - "query was: {query}" - ); - - // And the v3 envelope reshapes back into schema items. - assert_eq!(items.len(), 1); - assert_eq!(items[0].function.name, "GITHUB_STAR_A_REPOSITORY"); - assert_eq!( - items[0].function.description.as_deref(), - Some("Star a repository") - ); -} - -// ── execute_action over HTTP (correct v3 path/slug/body reach the wire) ──── - -// ── ensure_https ────────────────────────────────────────────────────────── - -#[test] -fn ensure_https_accepts_https_url() { - assert!(ensure_https("https://backend.composio.dev/api/v3/tools").is_ok()); -} - -#[test] -fn ensure_https_rejects_http_url() { - let err = ensure_https("http://backend.composio.dev/api/v3/tools").unwrap_err(); - assert!(err.to_string().contains("non-HTTPS")); -} - -#[test] -fn ensure_https_rejects_ftp_url() { - assert!(ensure_https("ftp://example.com").is_err()); -} - -// ── sanitize_error_message ──────────────────────────────────────────────── - -#[test] -fn sanitize_error_message_replaces_sensitive_fields() { - let msg = "Invalid connected_account_id value for entity_id: user-123"; - let sanitized = sanitize_error_message(msg); - assert!(!sanitized.contains("connected_account_id")); - assert!(!sanitized.contains("entity_id")); - assert!(sanitized.contains("[redacted]")); -} - -#[test] -fn sanitize_error_message_replaces_newlines_with_spaces() { - let msg = "line1\nline2\nline3"; - let sanitized = sanitize_error_message(msg); - assert!(!sanitized.contains('\n')); - assert!(sanitized.contains("line1")); - assert!(sanitized.contains("line2")); -} - -// ── failure messages stay byte-identical to the pre-module client ───────── - -#[tokio::test] -async fn http_failures_keep_their_user_facing_messages() { - use axum::{http::StatusCode, routing::get, Json, Router}; - let app = Router::new() - .route( - "/connected_accounts", - get(|| async { - ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": {"message": "Invalid API key"}})), - ) - }), - ) - .route( - "/tools", - get(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "") }), - ); - let tool = Arc::new(DirectComposioClient::new_with_v3_base( - "ck_test_direct", - start_mock_backend(app).await, - )); - - let err = tool.list_connections().await.unwrap_err(); - assert_eq!( - format!("{err:#}"), - "Composio v3 connected_accounts failed: HTTP 401: Invalid API key" - ); - let err = tool.list_tools(&[], &[]).await.unwrap_err(); - assert_eq!( - format!("{err:#}"), - "Composio v3 list_tool_schemas: HTTP 500" - ); -} - -#[tokio::test] -async fn connections_come_back_without_route_lifted_identity() { - use axum::{routing::get, Json, Router}; - let app = Router::new().route( - "/connected_accounts", - get(|| async { - Json(json!({"items": [ - {"id": " ca_1 ", "toolkit": "gmail", "status": "ACTIVE", "email": "a@b.c"}, - {"id": " ", "toolkit": "slack", "status": "ACTIVE"} - ]})) - }), - ); - let tool = Arc::new(DirectComposioClient::new_with_v3_base( - "ck_test_direct", - start_mock_backend(app).await, - )); - let connections = tool.list_connections().await.unwrap().connections; - assert_eq!(connections.len(), 1, "blank id dropped"); - assert_eq!(connections[0].id, "ca_1"); - // Identity is the host's to enrich from cached profiles. - assert!(connections[0].account_email.is_none()); -} diff --git a/crates/openhuman-core/src/integrations/composio/tools/direct_tests_sanitization_and_connected_accounts_tests.rs b/crates/openhuman-core/src/integrations/composio/tools/direct_tests_sanitization_and_connected_accounts_tests.rs deleted file mode 100644 index ca4b60c9ad6..00000000000 --- a/crates/openhuman-core/src/integrations/composio/tools/direct_tests_sanitization_and_connected_accounts_tests.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::*; - -#[test] -fn sanitize_error_message_truncates_long_messages() { - let long_msg = "x".repeat(500); - let sanitized = sanitize_error_message(&long_msg); - assert!( - sanitized.chars().count() <= 243, - "should be at most 240 chars + '...'" - ); - assert!( - sanitized.ends_with("..."), - "truncated message should end with '...'" - ); -} - -#[test] -fn sanitize_error_message_does_not_truncate_short_messages() { - let short = "Something went wrong"; - let sanitized = sanitize_error_message(short); - assert_eq!(sanitized, short); -} - -#[test] -fn sanitize_error_message_replaces_all_sensitive_variants() { - // camelCase variants - let msg = "Error for connectedAccountId and entityId and userId"; - let sanitized = sanitize_error_message(msg); - assert!( - !sanitized.contains("connectedAccountId"), - "camelCase connectedAccountId should be redacted" - ); - assert!( - !sanitized.contains("entityId"), - "camelCase entityId should be redacted" - ); - assert!( - !sanitized.contains("userId"), - "camelCase userId should be redacted" - ); -} diff --git a/crates/openhuman-core/src/integrations/composio/tools/list_connections.rs b/crates/openhuman-core/src/integrations/composio/tools/list_connections.rs index 3aef9e113c5..497d957e4c4 100644 --- a/crates/openhuman-core/src/integrations/composio/tools/list_connections.rs +++ b/crates/openhuman-core/src/integrations/composio/tools/list_connections.rs @@ -100,22 +100,25 @@ impl ComposioListConnectionsTool { } Ok(ComposioRoute::Direct(direct)) => { tracing::debug!("[composio-direct] list_connections.execute: direct variant"); - match direct_list_connections(&direct).await.map_err(|e| { - // [#1166 / Sentry TAURI-RUST-X9] Symmetric error - // routing with `ops.rs::composio_list_connections`. - // The agent-tool path can also fire 401s when a - // direct-mode user has a bad API key — without this - // hook the failure escapes the classifier and lands - // as an unclassified Sentry event. Render WITH the - // `[composio-direct]` anchor BEFORE reporting so the - // classifier arm in `is_provider_user_state_message` - // (gated on that prefix) actually fires. - let rendered = format!( - "[composio-direct] composio_list_connections (direct) failed: {e:#}" - ); - let rendered = redact_and_report(&live_config, "list_connections", &rendered); - anyhow::anyhow!("{rendered}") - }) { + match direct_list_connections(&live_config, &direct) + .await + .map_err(|e| { + // [#1166 / Sentry TAURI-RUST-X9] Symmetric error + // routing with `ops.rs::composio_list_connections`. + // The agent-tool path can also fire 401s when a + // direct-mode user has a bad API key — without this + // hook the failure escapes the classifier and lands + // as an unclassified Sentry event. Render WITH the + // `[composio-direct]` anchor BEFORE reporting so the + // classifier arm in `is_provider_user_state_message` + // (gated on that prefix) actually fires. + let rendered = format!( + "[composio-direct] composio_list_connections (direct) failed: {e:#}" + ); + let rendered = + redact_and_report(&live_config, "list_connections", &rendered); + anyhow::anyhow!("{rendered}") + }) { Ok(resp) => resp, Err(e) => return (live_config, Err(e)), } diff --git a/crates/openhuman-core/src/memory/binding.rs b/crates/openhuman-core/src/memory/binding.rs index 12c8a10ac1c..e7398669487 100644 --- a/crates/openhuman-core/src/memory/binding.rs +++ b/crates/openhuman-core/src/memory/binding.rs @@ -596,7 +596,7 @@ pub(crate) fn install_for_test( .insert(key, binding); } -pub use super::binding_rebind::rebind; +pub use super::binding_rebind::{current_for, rebind}; /// The bound memory driver for the workspace a whole [`Config`] names. /// diff --git a/crates/openhuman-core/src/memory/binding_rebind.rs b/crates/openhuman-core/src/memory/binding_rebind.rs index 136c8f21e55..1f87de021a7 100644 --- a/crates/openhuman-core/src/memory/binding_rebind.rs +++ b/crates/openhuman-core/src/memory/binding_rebind.rs @@ -1,4 +1,5 @@ -//! In-process engine switch: [`rebind`]. +//! In-process engine switch, [`rebind`], and the binding a workspace resolves +//! to after one, [`current_for`]. //! //! Split from [`crate::memory::binding`] to keep that file within the layout //! limit; it owns no state of its own and works on the binding cache there. @@ -11,6 +12,49 @@ use crate::core::subsystem::DriverClass; use super::binding::{for_workspace, MemoryBinding, BINDINGS}; +/// The binding `workspace_dir`'s shared memory tree resolves to now. +/// +/// For a caller holding a config from earlier, which may name an engine +/// switched away from since: binding that config again would rebuild the +/// evicted driver. A switch ([`rebind`]) evicts every other binding of the +/// workspace and binds the new engine, so after one the cache holds a single +/// binding for it, under the config switched to. Before any switch that is the +/// one boot bound. That config is resolved again through [`for_workspace`] +/// rather than the cached binding returned, so a transient fallback is retried +/// once its backoff has passed. `cfg` decides only when nothing is cached for +/// the workspace, or when bindings under more than one config are cached and +/// none of them can be told current. +/// +/// # Errors +/// +/// As [`for_workspace`]. +pub fn current_for( + workspace_dir: &Path, + cfg: &MemorySubsystemConfig, +) -> Result, String> { + let bound = bound_config(workspace_dir); + for_workspace(workspace_dir, bound.as_ref().unwrap_or(cfg)) +} + +/// The config of the one binding cached for `workspace_dir`'s shared tree. +fn bound_config(workspace_dir: &Path) -> Option { + let cache = BINDINGS.get()?; + let map = cache.read().ok()?; + let mut configs = map + .keys() + .filter(|(dir, subdir, _)| dir == workspace_dir && subdir == "memory") + .map(|(_, _, cfg)| cfg); + let bound = configs.next()?.clone(); + if configs.next().is_some() { + log::debug!( + "[memory:binding] workspace={} has bindings under more than one config; none is current", + workspace_dir.display() + ); + return None; + } + Some(bound) +} + /// Switch a workspace to `new_cfg` in process: evict the bindings the switch /// invalidates, re-point every context that serves the workspace, bind the new /// driver and announce it. diff --git a/crates/openhuman-core/src/memory/binding_remote_bind_tests.rs b/crates/openhuman-core/src/memory/binding_remote_bind_tests.rs index 9e8ef9728c6..ace439de595 100644 --- a/crates/openhuman-core/src/memory/binding_remote_bind_tests.rs +++ b/crates/openhuman-core/src/memory/binding_remote_bind_tests.rs @@ -167,6 +167,39 @@ mod transient_bind { ); } + /// A caller holding a stale config resolves the engine the workspace is + /// bound to, and through `for_workspace`: an expired fallback is retried, + /// never served as it was cached. + #[tokio::test] + async fn the_current_binding_retries_an_expired_fallback() { + let dir = tempfile::tempdir().unwrap(); + let cfg = bad_credential_cfg(); + let key = (dir.path().to_path_buf(), "memory".to_string(), cfg.clone()); + let expired = Arc::new( + crate::memory::binding_build::build(dir.path(), "memory", &cfg) + .retry_after(std::time::Duration::ZERO), + ); + BINDINGS + .get_or_init(Default::default) + .write() + .unwrap() + .insert(key, Arc::clone(&expired)); + let stale = MemorySubsystemConfig { + driver: "null".into(), + ..Default::default() + }; + let current = current_for(dir.path(), &stale).unwrap(); + assert_eq!( + current.fallback().unwrap().configured_driver, + "supermemory", + "the engine the workspace is bound to, not the caller's" + ); + assert!( + !Arc::ptr_eq(&expired, ¤t), + "an expired fallback is rebuilt" + ); + } + #[tokio::test] async fn an_admission_refusal_is_not_transient() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/openhuman-core/src/memory/sync/composio/providers/slack/rpc.rs b/crates/openhuman-core/src/memory/sync/composio/providers/slack/rpc.rs index 5d7aea11607..5373d6d783f 100644 --- a/crates/openhuman-core/src/memory/sync/composio/providers/slack/rpc.rs +++ b/crates/openhuman-core/src/memory/sync/composio/providers/slack/rpc.rs @@ -80,7 +80,7 @@ async fn list_slack_connections(config: &Config) -> Result direct_list_connections(&direct) + ComposioRoute::Direct(direct) => direct_list_connections(config, &direct) .await .map_err(|e| format!("[slack_ingest] list_connections (direct) failed: {e:#}")), } diff --git a/crates/openhuman-core/src/modules/connectors.rs b/crates/openhuman-core/src/modules/connectors.rs index 9227d12c8d4..8d28cb6651f 100644 --- a/crates/openhuman-core/src/modules/connectors.rs +++ b/crates/openhuman-core/src/modules/connectors.rs @@ -131,8 +131,20 @@ pub fn module_config(config: &Config) -> Result { "base_url": std::env::var("OPENHUMAN_COMPOSIO_DIRECT_BASE_V3").ok(), "state_dir": state_dir, }); - if let Some(base) = direct_base { - payload["base_url"] = serde_json::Value::String(base); + if let Some(base) = &direct_base { + payload["base_url"] = serde_json::Value::String(base.clone()); + } + // The host's proxy and TLS policy for Composio, resolved for the + // destination the module will dial. Part of the route description, + // so a change in the proxy settings reconfigures the module on the + // next call. Omitted when it is the module's default (contract 1.10). + let destination = direct_base + .as_deref() + .unwrap_or("https://backend.composio.dev/api/v3"); + if let Some(transport) = + crate::integrations::composio::client::module_transport(destination) + { + payload["transport"] = serde_json::json!(transport); } Ok(payload) } @@ -308,6 +320,38 @@ where .map_err(|error| format!("{member}: {error}")) } +/// Call a member that carries its own credential, without touching the route. +/// +/// `ListConnectionsDirect` and `ListToolsDirect` read as the credential on the +/// request, so the module's configured route is neither used nor replaced. That +/// is why this does not reconcile the route first: a host serving several +/// credentials from one module must not have one caller's configuration +/// overwrite another's mid-call, and a config that names no route here would +/// otherwise send the module `{"route": "none"}` in the middle of a read. +/// +/// # Errors +/// +/// As [`call`]. +pub async fn call_stateless( + config: &Config, + member: &str, + request: Request, +) -> Result +where + Request: Serialize + Send, + Reply: DeserializeOwned, +{ + // The module's own HTTP deadline is 30s; give the bus a little longer so a + // slow Composio surfaces as the module's message, not a bus timeout. + const STATELESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); + ops::ensure_loaded(config, MODULE_ID).await?; + let proxy = proxy_to_serving().await?.with_timeout(STATELESS_TIMEOUT); + proxy + .call::(member, (request,)) + .await + .map_err(|error| format!("{member}: {error}")) +} + /// Call a long-running member with a deadline sized for it. /// /// The default bus deadline (30s) fits request-shaped members. `Sync` is not diff --git a/crates/openhuman-core/src/modules/connectors_tests.rs b/crates/openhuman-core/src/modules/connectors_tests.rs index c4e2cb8ca06..742f611cfb4 100644 --- a/crates/openhuman-core/src/modules/connectors_tests.rs +++ b/crates/openhuman-core/src/modules/connectors_tests.rs @@ -88,6 +88,49 @@ fn direct_mode_takes_the_api_key_from_the_config_file() { assert!(blob.get("state_dir").is_some()); } +#[test] +fn direct_mode_hands_the_module_the_hosts_proxy_policy() { + // Part of the route description, so a proxy change reconfigures the module. + use crate::config::schema::{ProxyConfig, ProxyScope}; + use crate::config::{runtime_proxy_config, set_runtime_proxy_config}; + + let _env = crate::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut config = bare_config(); + config.composio.mode = COMPOSIO_MODE_DIRECT.to_string(); + config.composio.api_key = Some("sk-from-file".to_string()); + + let previous = runtime_proxy_config(); + set_runtime_proxy_config(ProxyConfig { + enabled: true, + https_proxy: Some("http://127.0.0.1:3128".into()), + no_proxy: vec!["localhost".into()], + // Scoped to Composio so no other test's loopback request is proxied. + scope: ProxyScope::Services, + services: vec!["tool.composio".into()], + ..ProxyConfig::default() + }); + let proxied = module_config(&config).expect("resolves"); + set_runtime_proxy_config(ProxyConfig::default()); + let plain = module_config(&config).expect("resolves"); + set_runtime_proxy_config(previous); + + assert_eq!(proxied["transport"]["proxy_url"], "http://127.0.0.1:3128"); + assert_eq!(proxied["transport"]["no_proxy"][0], "localhost"); + if !cfg!(target_os = "windows") { + assert!( + plain.get("transport").is_none(), + "the default policy is not sent" + ); + } + assert_ne!( + super::fingerprint(&proxied), + super::fingerprint(&plain), + "a proxy change must change the route fingerprint" + ); +} + #[test] fn direct_mode_without_a_key_is_refused() { // Rather than silently falling back to the backend route, which would send diff --git a/crates/openhuman-core/src/modules/memory/capabilities.rs b/crates/openhuman-core/src/modules/memory/capabilities.rs index 06618dced6e..01660f583e5 100644 --- a/crates/openhuman-core/src/modules/memory/capabilities.rs +++ b/crates/openhuman-core/src/modules/memory/capabilities.rs @@ -10,7 +10,7 @@ use tinymemory_api::capabilities::{Capabilities, Capability}; /// Checked against the registry pin by `the_capability_list_matches_the_pinned_release`, /// so bumping the pin without re-reading the list is a red test rather than a /// silent over-claim. -pub(crate) const ARTIFACT_CAPABILITIES_PIN: &str = "1.21.1"; +pub(crate) const ARTIFACT_CAPABILITIES_PIN: &str = "1.22.2"; /// The capability families the **pinned artifact** actually serves. /// @@ -56,6 +56,12 @@ pub(crate) const ARTIFACT_CAPABILITIES_PIN: &str = "1.21.1"; /// crates/tinymemory-bus/src/names.rs` is empty. The release fixes the hosted /// wire's episodic import (tinymemory#179), which the module does not serve, /// so the advertised families stay the same. +/// Re-read at `v1.22.2`: `git diff v1.21.1..v1.22.2 -- +/// crates/tinymemory-api/src/capabilities.rs crates/tinymemory-bus/src/capabilities.rs +/// crates/tinymemory-bus/src/names.rs` is empty. v1.22.0 drops the unread +/// usage plumbing, v1.22.1 fixes the copy's documents step (tinymemory#182), +/// and v1.22.2 fixes hosted search across synced sources and a hosted copy +/// run again (tinymemory#183, #184). None adds or removes a family. /// /// Read at tag `v1.3.0`. Unchanged from v1.2.0 — the release added members /// within existing families (`retry_failed`, the diagnostics trio, diff --git a/crates/openhuman-core/src/modules/memory_host.rs b/crates/openhuman-core/src/modules/memory_host.rs index 0544b85b466..eda45ad9d18 100644 --- a/crates/openhuman-core/src/modules/memory_host.rs +++ b/crates/openhuman-core/src/modules/memory_host.rs @@ -200,21 +200,24 @@ impl ComposioCallbacks { .await .map_err(|e| method_error(format!("list_connections (backend): {e}")))?, ComposioRoute::Direct(direct) => { - direct_list_connections(&direct).await.map_err(|e| { - // [#1166 / Sentry TAURI-RUST-X9] The v3 `/connected_accounts` - // 401 shape has to reach the observability classifier, and it - // only fires on a message carrying the `[composio-direct]` - // anchor. Report it here rather than relying on the caller: - // by the time this reaches the module it is wrapped in a - // `MethodFailed` envelope, and the classifier that keys on - // the anchor runs in this process, not that one. - let rendered = format!("[composio-direct] list_connections (direct): {e:#}"); - crate::integrations::composio::ops::report_composio_op_error( - "list_connections", - &rendered, - ); - method_error(rendered) - })? + direct_list_connections(&config, &direct) + .await + .map_err(|e| { + // [#1166 / Sentry TAURI-RUST-X9] The v3 `/connected_accounts` + // 401 shape has to reach the observability classifier, and it + // only fires on a message carrying the `[composio-direct]` + // anchor. Report it here rather than relying on the caller: + // by the time this reaches the module it is wrapped in a + // `MethodFailed` envelope, and the classifier that keys on + // the anchor runs in this process, not that one. + let rendered = + format!("[composio-direct] list_connections (direct): {e:#}"); + crate::integrations::composio::ops::report_composio_op_error( + "list_connections", + &rendered, + ); + method_error(rendered) + })? } }; Ok(response.connections) diff --git a/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs b/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs index ebb06a873f0..0221218e65d 100644 --- a/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs +++ b/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs @@ -94,63 +94,63 @@ pub(crate) const TINYCONNECTORS: ModuleRecord = ModuleRecord { description: "OAuth connector integrations: accounts, actions, triggers, and record sync", bus_name: "ai.tinyhumans.connectors.Composio", object_path: "/ai/tinyhumans/connectors/Composio", - version: "0.11.0", - release_url: "https://github.com/tinyhumansai/tinyconnectors/releases/tag/v0.11.0", + version: "0.12.1", + release_url: "https://github.com/tinyhumansai/tinyconnectors/releases/tag/v0.12.1", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinyconnectors-0.11.0-ubuntu-24.04-x86_64.tar.gz", - sha256: "e57a7e20ee7071695eac7537c55487e7bc41713c119b0931c4793940f4039b61", + archive: "tinyconnectors-0.12.1-ubuntu-24.04-x86_64.tar.gz", + sha256: "4c645157682328371a28d8c653708cb4be2ada4733b7010b908a3c403ab57b7a", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinyconnectors-0.11.0-ubuntu-24.04-arm64.tar.gz", - sha256: "0baf09c899870840f3cae79f68348dd11aa999a29e0d1c2fc73cbf3ef1315013", + archive: "tinyconnectors-0.12.1-ubuntu-24.04-arm64.tar.gz", + sha256: "6897b3d7933c2a9481d501be3be0e427b20aea89108e447296af11353f6516ff", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinyconnectors-0.11.0-ubuntu-22.04-x86_64.tar.gz", - sha256: "72d9885506329465cc8c4b544304f32882d9d2a5c73ec2e21992a8563e4e3b30", + archive: "tinyconnectors-0.12.1-ubuntu-22.04-x86_64.tar.gz", + sha256: "62d0f74571a28ab8f55fbca61725b6859b6abb0cde9fc4b30b726c21e34e32e9", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinyconnectors-0.11.0-ubuntu-22.04-arm64.tar.gz", - sha256: "a9a038c9d3d37da612eea6ec7ca9df7ab8a5922ec3152ab3ee57a2da2f3d9a14", + archive: "tinyconnectors-0.12.1-ubuntu-22.04-arm64.tar.gz", + sha256: "784f10b06ead43838d3dcdfe57d1d3f67749dd9fcd7af3ee8029db70400a10e3", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinyconnectors-0.11.0-macos-26-arm64.tar.gz", - sha256: "904d34c761d080ba78e23deb7c2347b3b648e891f54dec4d0f48f79831bec760", + archive: "tinyconnectors-0.12.1-macos-26-arm64.tar.gz", + sha256: "cfc0ead03b6aaf701223833a8c7f0e362a7b34e00d3186c0e8fb20f65c079f9b", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinyconnectors-0.11.0-macos-26-x86_64.tar.gz", - sha256: "63e2c4f251bc7805dd1eb38bdc1eb2386ec1b5141a0a8c4fd8347620ec86a003", + archive: "tinyconnectors-0.12.1-macos-26-x86_64.tar.gz", + sha256: "1f84de545402d0234bd5f77843dd1c603eb012780d0d539ee6e3cbfed4b8b472", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinyconnectors-0.11.0-macos-15-arm64.tar.gz", - sha256: "a693e2c49281bc1cee82c9999434282bfe9eb8575e1ae05bf8a634e63c299676", + archive: "tinyconnectors-0.12.1-macos-15-arm64.tar.gz", + sha256: "c294dacecc3f16e0e0207996a2679a4d9821934066dee13706b7073ba59087c6", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinyconnectors-0.11.0-macos-15-x86_64.tar.gz", - sha256: "a5ab99e9356d511212ce34b5a7d203fde90b0a789747e8a7b95b2b6ed5dd2183", + archive: "tinyconnectors-0.12.1-macos-15-x86_64.tar.gz", + sha256: "5bd5fdc2c4692e7cba0b005fe3bcd1687b250369a59106d19251ea9d88b20c80", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinyconnectors-0.11.0-windows-2025-x86_64.zip", - sha256: "1d9b4b9e89c207f2154292bc0e3447f2a6709110b4fc551c0fef2561e0e8f585", + archive: "tinyconnectors-0.12.1-windows-2025-x86_64.zip", + sha256: "0495cde55856fca1c08746def82c3d026970cab9451b9b917f2d6ebfd043e220", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinyconnectors-0.11.0-windows-2022-x86_64.zip", - sha256: "2cf82038c10db0225b27d4ff6ae5c962b5406eb9d35f17b7ea615e73fa2e7fcb", + archive: "tinyconnectors-0.12.1-windows-2022-x86_64.zip", + sha256: "d27b18cba4a15dd702221712e729eabbe57cae01565054734ebcec1204ab4720", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinyconnectors-0.11.0-windows-11-arm64.zip", - sha256: "e234763ed750c0c9b252990825a7339beae375d5434ca7a5ae54615f24224888", + archive: "tinyconnectors-0.12.1-windows-11-arm64.zip", + sha256: "dcacaacdb4fb2d7adbc1bd418ffc0fef4496af96621e7ef12e8c63f335fdd7ef", }, ], // Lazy: a user with no connected accounts should not pay to load it, and diff --git a/crates/openhuman-core/src/modules/registry/records_memory_juice.rs b/crates/openhuman-core/src/modules/registry/records_memory_juice.rs index ed0c24249b0..c97fe0aea2b 100644 --- a/crates/openhuman-core/src/modules/registry/records_memory_juice.rs +++ b/crates/openhuman-core/src/modules/registry/records_memory_juice.rs @@ -9,63 +9,63 @@ pub(crate) const TINYMEMORY: ModuleRecord = ModuleRecord { description: "Local memory engine: store, ranked recall, and portable export", bus_name: "ai.tinyhumans.tinymemory.Memory", object_path: "/ai/tinyhumans/tinymemory/Memory", - version: "1.21.1", - release_url: "https://github.com/tinyhumansai/tinymemory/releases/tag/v1.21.1", + version: "1.22.2", + release_url: "https://github.com/tinyhumansai/tinymemory/releases/tag/v1.22.2", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinymemory-module-1.21.1-ubuntu-24.04-x86_64.tar.gz", - sha256: "c44e5f428ce7024e65e951041a7586c777b0a7a390dd757316f9db2f2d869c8b", + archive: "tinymemory-module-1.22.2-ubuntu-24.04-x86_64.tar.gz", + sha256: "6679043f32777c16fbc39e83ff5e537561c03af45f920982c59bd125675fb3a8", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinymemory-module-1.21.1-ubuntu-24.04-arm64.tar.gz", - sha256: "cb6476ea3337b240efc92718ad3747e4e4fa45afed4a50999cae9c95a5ffdfd9", + archive: "tinymemory-module-1.22.2-ubuntu-24.04-arm64.tar.gz", + sha256: "86cce8cd940dc20ddb59aacee8ca42ea5d4e52718952cc2c44936baf7267de56", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinymemory-module-1.21.1-ubuntu-22.04-x86_64.tar.gz", - sha256: "f208b1bf05e52af4ef7a9902d656827c471aa0a50aa3015ddb0ef9058e524c53", + archive: "tinymemory-module-1.22.2-ubuntu-22.04-x86_64.tar.gz", + sha256: "89f1da9f53e939263f3a7d9f4c6c19b52951bde308a9beb6efedc2b6c272163e", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinymemory-module-1.21.1-ubuntu-22.04-arm64.tar.gz", - sha256: "62cd46e2f038081c5d7ffd63ffe0482dcd92e54b7b8eae57c814aa614c00d65e", + archive: "tinymemory-module-1.22.2-ubuntu-22.04-arm64.tar.gz", + sha256: "14acb8a0e6d2733a00809f77a030522397593e0cd563ab07917370d0bf7a8a1b", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinymemory-module-1.21.1-macos-26-arm64.tar.gz", - sha256: "4537bc352d716e5f71e50c64dae4ce5c763f4109bd6cb4ad3d3cf9b2229e6c87", + archive: "tinymemory-module-1.22.2-macos-26-arm64.tar.gz", + sha256: "c8461a88f8c85b5baeee3b4c1d46d7c086803976eee68e3f10ef3d6f9083fc73", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinymemory-module-1.21.1-macos-26-x86_64.tar.gz", - sha256: "c785386c02f6d1c4095718979d60a55705489ce988eee524cfa2be9190f7b829", + archive: "tinymemory-module-1.22.2-macos-26-x86_64.tar.gz", + sha256: "089c6248ef5065f31288a78b24f0d686bfbf48ad87770e68f6260d6b8d6337e5", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinymemory-module-1.21.1-macos-15-arm64.tar.gz", - sha256: "45e40ff8a92b4b5a49c7ad294d3fa560f2a4798e86623d69cb494b624dbf99b0", + archive: "tinymemory-module-1.22.2-macos-15-arm64.tar.gz", + sha256: "bcabaf6e0709fc1ef2c4fa4750933ef6535fb37b792d6c55be9f682473faf815", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinymemory-module-1.21.1-macos-15-x86_64.tar.gz", - sha256: "c0b08056b65430fdb6256c4fc260a445a40c33a96624a8202ac82f1c51e483ed", + archive: "tinymemory-module-1.22.2-macos-15-x86_64.tar.gz", + sha256: "88eeb0fad76049555c92aee094716bb197cad72c9cceada41aa232dc746abb34", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinymemory-module-1.21.1-windows-2025-x86_64.zip", - sha256: "61dc219dbb1872c9ecf4bba5908c8a596f057da5fe2c5374e42b40d66ce1534f", + archive: "tinymemory-module-1.22.2-windows-2025-x86_64.zip", + sha256: "1cc6a36e453a54f6e1efb539f99af63de806d35e8861a99486154f0742771e61", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinymemory-module-1.21.1-windows-2022-x86_64.zip", - sha256: "a9540bdcaa60f17eca1066e7903412a27738fa804a91e302e81d8c69abdebdba", + archive: "tinymemory-module-1.22.2-windows-2022-x86_64.zip", + sha256: "e4f49910e2b56835121c28b1031740738839fb8aad115cf6dfd28c803c001788", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinymemory-module-1.21.1-windows-11-arm64.zip", - sha256: "ecf8a25b5991e223f7a7b8d4b4f822ed3f284b79022e7c7e38ffbc4b8f506840", + archive: "tinymemory-module-1.22.2-windows-11-arm64.zip", + sha256: "002caacf4659a400b51468dadfc374e75dd6f62f260790525d376ea4912f7101", }, ], // Eager, unlike the two codecs above. A codec that is never asked for should diff --git a/crates/openhuman-core/src/web_chat/ops/turn_guards.rs b/crates/openhuman-core/src/web_chat/ops/turn_guards.rs index cf0aa5cab95..1fe830f4f83 100644 --- a/crates/openhuman-core/src/web_chat/ops/turn_guards.rs +++ b/crates/openhuman-core/src/web_chat/ops/turn_guards.rs @@ -151,12 +151,15 @@ pub(crate) fn sentry_suppression_reason(detailed: &str) -> Option<&'static str> /// under one tag would rebuild, in the dashboard, exactly the conflation this /// change removed from the code (#5804). /// -/// Pure over the formatted error string, for the same reason its neighbour is. +/// The bound is a typed field on the harness's hosted error +/// (`HostedError::timeout_bound`); the turn runner turns it into a +/// [`TurnTimeoutBound`](crate::agent::error::TurnTimeoutBound) phrase in the +/// flattened error, and this function reads that phrase back through the same +/// type rather than a second spelling. Pure over the formatted error string, +/// for the same reason its neighbour is. pub(crate) fn timeout_bound_tag(detailed: &str) -> &'static str { - if detailed.contains("per-model-call ceiling") { - "per_model_call" - } else if detailed.contains("remaining wall-clock budget") { - "run_remaining" + if let Some(bound) = crate::agent::error::TurnTimeoutBound::from_message(detailed) { + bound.tag() } else if super::super::web_errors::is_turn_timeout_error(detailed) { "unclassified_timeout" } else { diff --git a/crates/openhuman-core/src/web_chat/web_tests_start_chat_ingress_tests.rs b/crates/openhuman-core/src/web_chat/web_tests_start_chat_ingress_tests.rs index 03417b785d0..2f29e8662e4 100644 --- a/crates/openhuman-core/src/web_chat/web_tests_start_chat_ingress_tests.rs +++ b/crates/openhuman-core/src/web_chat/web_tests_start_chat_ingress_tests.rs @@ -628,3 +628,30 @@ fn timeout_bound_tag_separates_the_two_harness_ceilings() { "a non-timeout error must not carry a timeout bound" ); } + +/// The hosted path sanitizes the harness's timeout text, so the bound reaches +/// the Sentry tag through the typed `HostedError::timeout_bound` field. The +/// user-facing class must stay `turn_timeout` for both bounds. +#[test] +fn hosted_timeout_bound_reaches_the_sentry_tag_and_keeps_the_user_class() { + use tinyagents_harness::runtime::{HostedError, HostedErrorKind, TimeoutBound}; + + for (bound, tag) in [ + (TimeoutBound::PerModelCall, "per_model_call"), + (TimeoutBound::Run, "run_remaining"), + ] { + let hosted = HostedError { + kind: HostedErrorKind::Timeout, + message: "hosted agent invocation timed out".to_string(), + timeout_bound: Some(bound), + run: None, + }; + let err = crate::agent::tinyagents::hosted_error::run_error_from_hosted(hosted); + let flattened = format!("run_chat_task failed error=tinyagents harness run failed: {err}"); + assert_eq!(super::super::ops::timeout_bound_tag(&flattened), tag); + assert_eq!( + super::super::web_errors::classify_inference_error(&flattened).error_type, + "turn_timeout" + ); + } +} diff --git a/scripts/__tests__/e2e-web-ports.test.mjs b/scripts/__tests__/e2e-web-ports.test.mjs index bec862184d8..4acc2a4b46b 100644 --- a/scripts/__tests__/e2e-web-ports.test.mjs +++ b/scripts/__tests__/e2e-web-ports.test.mjs @@ -23,6 +23,10 @@ import { fileURLToPath } from "node:url"; const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const MARKER = "openhuman-e2e-bundle.marker"; const APP_SCRIPTS = ["e2e-ports.sh", "e2e-web-session.sh", "e2e-web-build.sh"]; +// What the session stats to decide the bundle is not older than its sources +// (#5919). The fake checkout has to carry them or the gate refuses every tree +// here for a missing input instead of exercising what the test is about. +const BUNDLE_INPUTS = ["src/App.tsx", "public/favicon.ico", "index.html", "vite.config.ts"]; function writeExecutable(file, body) { fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -47,6 +51,12 @@ function makeTree() { path.join(root, "scripts", "load-dotenv.sh"), ); + for (const rel of BUNDLE_INPUTS) { + const file = path.join(root, "app", rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, ""); + } + const record = (name) => `echo "${name} $*" >> "${log}"`; // The mock backend "starts" and exits at once, so the health probe waits for @@ -119,6 +129,21 @@ function markBundle(tree, { mockPort, corePort }) { path.join(distWeb, ".e2e-build-ports.json"), `{"e2e_mock_port":"${mockPort}","openhuman_core_port":"${corePort}"}\n`, ); + backdateBundleInputs(tree); +} + +/** + * Age every bundle input so the marker is strictly newer, the way a real build + * leaves them: `build:web` reads the sources and the marker is written after it + * returns. Directories come last because writing a file inside one bumps its + * mtime. + */ +function backdateBundleInputs(tree) { + const marker = path.join(tree.root, "app", "dist-web", MARKER); + const when = fs.statSync(marker).mtimeMs / 1000 - 10; + for (const rel of [...BUNDLE_INPUTS, "src", "public"]) { + fs.utimesSync(path.join(tree.root, "app", rel), when, when); + } } function run(tree, script, env = {}) { @@ -549,6 +574,96 @@ test("the recorded build ports come from the pre-dotenv selection", async () => } }); +// ── the bundle has to be newer than the sources it was built from ────────── + +test("the session refuses a bundle older than the sources", async () => { + // The defect: the session serves the prebuilt `dist-web`, so a spec re-run + // after editing `app/src` asserted against the PREVIOUS bundle and passed. + // A revert-proof or a fault injection then proves nothing (#5919). + const tree = makeTree(); + try { + const base = await freeBase(); + markBundle(tree, { mockPort: base, corePort: base + 1 }); + stubCore(tree, base + 1); + fs.writeFileSync(path.join(tree.root, "app", "src", "App.tsx"), "// edited\n"); + + const res = run(tree, "e2e-web-session.sh", { E2E_PORT_BASE: String(base) }); + + assert.equal(res.status, 1, res.output); + assert.match(res.output, /is older than the sources it was built from/); + assert.match(res.output, /Newer than the bundle: .*app\/src/); + assert.ok( + !tree.calls().some((call) => call.includes("exec playwright")), + `the specs must not run against a stale bundle:\n${tree.calls().join("\n")}`, + ); + } finally { + tree.cleanup(); + } +}); + +test("a file added after the build is caught even when the file itself is old", async () => { + // Why directories are stat'd and not only files: adding, removing or renaming + // one need not leave any surviving file newer than the marker, but it does + // bump the directory's own mtime. + const tree = makeTree(); + try { + const base = await freeBase(); + markBundle(tree, { mockPort: base, corePort: base + 1 }); + stubCore(tree, base + 1); + const added = path.join(tree.root, "app", "src", "Added.tsx"); + fs.writeFileSync(added, ""); + const old = fs.statSync(path.join(tree.root, "app", "index.html")).mtimeMs / 1000; + fs.utimesSync(added, old, old); + + const res = run(tree, "e2e-web-session.sh", { E2E_PORT_BASE: String(base) }); + + assert.equal(res.status, 1, res.output); + assert.match(res.output, /is older than the sources it was built from/); + } finally { + tree.cleanup(); + } +}); + +test("the session runs the specs when the bundle is newer than the sources", async () => { + // The other half: an untouched checkout must not be refused. + const tree = makeTree(); + try { + const base = await freeBase(); + markBundle(tree, { mockPort: base, corePort: base + 1 }); + stubCore(tree, base + 1); + + const res = run(tree, "e2e-web-session.sh", { E2E_PORT_BASE: String(base) }); + + assert.doesNotMatch(res.output, /older than the sources/); + assert.ok( + waitForCall(tree, "exec playwright"), + `expected the specs to run:\n${tree.calls().join("\n")}`, + ); + } finally { + tree.cleanup(); + } +}); + +test("a bundle input that moved fails the check instead of disabling it", async () => { + // `find` on a path that does not exist reports nothing, so a renamed input + // would silently turn the whole gate off and serve the stale bundle again. + const tree = makeTree(); + try { + const base = await freeBase(); + markBundle(tree, { mockPort: base, corePort: base + 1 }); + stubCore(tree, base + 1); + fs.rmSync(path.join(tree.root, "app", "vite.config.ts")); + + const res = run(tree, "e2e-web-session.sh", { E2E_PORT_BASE: String(base) }); + + assert.equal(res.status, 1, res.output); + assert.match(res.output, /bundle input .*vite\.config\.ts does not exist/); + assert.match(res.output, /Update the input list/); + } finally { + tree.cleanup(); + } +}); + // ── the lane that owns these tests has to run for them ───────────────────── test("a change to either web E2E script arms the lane that runs this suite", () => { diff --git a/scripts/__tests__/externalize-inline-tests.test.mjs b/scripts/__tests__/externalize-inline-tests.test.mjs new file mode 100644 index 00000000000..7db22259c80 --- /dev/null +++ b/scripts/__tests__/externalize-inline-tests.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { codeMask, externalizeSource } from "../externalize-inline-tests.mjs"; + +const DECL = '#[cfg(test)]\n#[path = "lib_tests.rs"]\nmod tests;\n'; + +test("moves a trailing inline module and dedents its body", () => { + const src = "pub fn a() {}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn t() {\n a();\n }\n}\n"; + const out = externalizeSource(src, "lib"); + assert.equal(out.source, `pub fn a() {}\n\n${DECL}`); + assert.equal(out.moves.length, 1); + assert.equal(out.moves[0].fileName, "lib_tests.rs"); + assert.equal(out.moves[0].body, "use super::*;\n\n#[test]\nfn t() {\n a();\n}\n"); + assert.deepEqual(out.skipped, []); +}); + +test("braces in strings, raw strings, chars and comments do not end the block", () => { + const src = [ + "#[cfg(test)]", + "mod tests {", + ' const A: &str = "}";', + ' const B: &str = r#"} mod x { "#;', + " const C: char = '}';", + " // }", + " /* } /* } */ } */", + " fn f<'a>(x: &'a str) -> &'a str { x }", + "}", + "fn after() {}", + "", + ].join("\n"); + const out = externalizeSource(src, "lib"); + assert.equal(out.moves[0].body.trimEnd().split("\n").length, 6); + assert.equal(out.source, `${DECL}fn after() {}\n`); +}); + +test("does not dedent lines that sit inside a multi-line string", () => { + const src = '#[cfg(test)]\nmod tests {\n const S: &str = "a\n b";\n}\n'; + assert.equal(externalizeSource(src, "lib").moves[0].body, 'const S: &str = "a\n b";\n'); +}); + +test("ignores a `mod tests {` that only appears inside a string or comment", () => { + const src = 'const S: &str = r#"\n#[cfg(test)]\nmod tests {\n}\n"#;\n// #[cfg(test)]\n// mod tests {\n'; + const out = externalizeSource(src, "lib"); + assert.equal(out.moves.length, 0); + assert.equal(out.source, src); +}); + +test("keeps sibling attributes and names extra modules after the module", () => { + const src = "#[cfg(test)]\n#[allow(clippy::unwrap_used)]\nmod tests {\n fn a() {}\n}\n\n#[cfg(test)]\nmod fixtures {\n fn b() {}\n}\n"; + const out = externalizeSource(src, "lib"); + assert.deepEqual(out.moves.map((m) => m.fileName), ["lib_tests.rs", "lib_fixtures_tests.rs"]); + assert.match(out.source, /#\[allow\(clippy::unwrap_used\)\]\n#\[path = "lib_tests.rs"\]\nmod tests;/); + assert.match(out.source, /#\[path = "lib_fixtures_tests.rs"\]\nmod fixtures;/); +}); + +test("reads a multi-line attribute, and leaves a non-test module behind one alone", () => { + const lint = "#[allow(\n missing_docs,\n reason = \"generated\"\n)]\npub(crate) mod exports {\n fn a() {}\n}\n"; + const plain = externalizeSource(lint, "lib"); + assert.equal(plain.moves.length, 0); + assert.deepEqual(plain.skipped, []); + + const gated = "#[cfg(test)]\n#[allow(\n missing_docs,\n reason = \"fixture\"\n)]\nmod tests {\n fn a() {}\n}\n"; + const out = externalizeSource(gated, "lib"); + assert.equal(out.moves.length, 1); + assert.equal( + out.source, + '#[cfg(test)]\n#[allow(\n missing_docs,\n reason = "fixture"\n)]\n#[path = "lib_tests.rs"]\nmod tests;\n', + ); +}); + +test("leaves cfg(not(test)) and plain inline modules alone", () => { + const src = "#[cfg(not(test))]\nmod real {\n fn a() {}\n}\nmod plain {\n fn b() {}\n}\n"; + const out = externalizeSource(src, "lib"); + assert.equal(out.moves.length, 0); + assert.equal(out.source, src); +}); + +test("writes a module nested in an inline wrapper to the directory rustc implies", () => { + const src = "#[cfg(target_os = \"macos\")]\nmod imp {\n fn a() {}\n\n #[cfg(test)]\n mod tests {\n use super::*;\n\n #[test]\n fn t() {}\n }\n}\n"; + const plain = externalizeSource(src, "cf_type"); + assert.equal(plain.moves[0].fileName, "cf_type_tests.rs"); + assert.equal(plain.moves[0].relDir, "cf_type/imp"); + assert.equal(plain.moves[0].body, "use super::*;\n\n#[test]\nfn t() {}\n"); + assert.match(plain.source, / #\[cfg\(test\)\]\n #\[path = "cf_type_tests.rs"\]\n mod tests;\n}\n$/); + assert.deepEqual(plain.skipped, []); + // A `mod.rs` or crate root owns its directory, so the wrapper's directory sits beside it. + assert.equal(externalizeSource(src, "mod").moves[0].relDir, "imp"); +}); + +test("names a second nested module after its wrapper when the file name is taken", () => { + const block = (wrapper) => + `mod ${wrapper} {\n #[cfg(test)]\n mod tests {\n fn a() {}\n }\n}\n`; + const out = externalizeSource(`${block("imp")}\n${block("raise")}`, "ops"); + assert.deepEqual(out.moves.map((m) => m.fileName), ["ops_tests.rs", "ops_raise_tests.rs"]); + assert.deepEqual(out.moves.map((m) => m.relDir), ["ops/imp", "ops/raise"]); + assert.deepEqual(out.skipped, []); +}); + +test("puts a bin crate root's tests in a subdirectory Cargo will not build as a binary", () => { + const src = "fn main() {}\n\n#[cfg(test)]\nmod tests {\n fn a() {}\n}\n"; + const out = externalizeSource(src, "tool", new Set(), { subdir: "tool" }); + assert.match(out.source, /#\[path = "tool\/tool_tests.rs"\]\nmod tests;/); + assert.equal(out.moves[0].relDir, "tool"); +}); + +test("reports a test module nested inside a function instead of moving it", () => { + const src = "fn f() {\n #[cfg(test)]\n mod tests {\n fn a() {}\n }\n}\n"; + const out = externalizeSource(src, "lib"); + assert.equal(out.moves.length, 0); + assert.equal(out.skipped.length, 1); + assert.match(out.skipped[0].reason, /nested somewhere other than/); +}); + +test("refuses a body that declares out-of-line modules", () => { + const src = '#[cfg(test)]\nmod tests {\n #[path = "x.rs"]\n mod x;\n}\n'; + const out = externalizeSource(src, "lib"); + assert.equal(out.moves.length, 0); + assert.match(out.skipped[0].reason, /out-of-line/); +}); + +test("refuses to overwrite an existing test file", () => { + const src = "#[cfg(test)]\nmod tests {\n fn a() {}\n}\n"; + const out = externalizeSource(src, "lib", new Set(["lib_tests.rs"])); + assert.equal(out.moves.length, 0); + assert.match(out.skipped[0].reason, /already exists/); +}); + +test("codeMask treats lifetimes as code and char literals as data", () => { + const src = "fn f<'a>(c: char) { let x = '{'; }"; + const mask = codeMask(src); + assert.equal(mask[src.indexOf("'a")], 1); + assert.equal(mask[src.indexOf("'{'") + 1], 0); +}); diff --git a/scripts/ci/check-openhuman-rust-layout.mjs b/scripts/ci/check-openhuman-rust-layout.mjs index d471de1dfe6..e2a476a3fb7 100644 --- a/scripts/ci/check-openhuman-rust-layout.mjs +++ b/scripts/ci/check-openhuman-rust-layout.mjs @@ -38,7 +38,7 @@ const LEGACY_LIMIT_ENTRIES = [ // under the general 750 limit, so it needs no exception at all. // The session-todo integration added transcript metadata construction to // this already-exempt composition seam. Keep its allowance exact. - ["crates/openhuman-core/src/agent/session_host/runtime_session.rs", 1966], + ["crates/openhuman-core/src/agent/session_host/runtime_session.rs", 1965], // Session-host factory still assembles the product's deliberately coupled // provider, security, memory, tool and prompt policy. Generic session // state moved to tinyagents-runtime; this remaining composition is split in diff --git a/scripts/externalize-inline-tests.mjs b/scripts/externalize-inline-tests.mjs new file mode 100644 index 00000000000..9b5d489f4de --- /dev/null +++ b/scripts/externalize-inline-tests.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node + +// Move inline `#[cfg(test)] mod name { ... }` blocks out of Rust sources and +// into sibling `*_tests.rs` files, leaving the repo's out-of-line form behind: +// +// #[cfg(test)] +// #[path = "foo_tests.rs"] +// mod tests; +// +// The module stays a child of the same parent, so `use super::*;`, privacy and +// every relative path inside the body keep working unchanged. Run it against +// the core crate or any vendored submodule checkout: +// +// node scripts/externalize-inline-tests.mjs ... [--write] [--no-fmt] +// +// Without `--write` it only reports. It exits 1 while any inline test module +// remains, including the ones it refuses to move and lists as "manual". + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SKIPPED_DIRS = new Set(["tests", "benches", "examples", "target", "docs"]); +const TEST_FILE_NAMES = new Set(["tests.rs", "test.rs"]); +const CFG_TEST_ATTR = /^#\[cfg\(.*\btest\b.*\)\]$/; +const CFG_NOT_TEST = /\bnot\(\s*test\s*\)/; +const MOD_OPEN = /^([ \t]*)((?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+(\w+))[ \t]*\{/gm; +const RAW_STRING = /b?r(#*)"/y; +const IDENT_CHAR = /[A-Za-z0-9_]/; + +/** One byte per source character: 1 where it is code, 0 inside a string, char literal or comment. */ +export function codeMask(src) { + const n = src.length; + const code = new Uint8Array(n).fill(1); + let i = 0; + while (i < n) { + const c = src[i]; + if (c === "/" && src[i + 1] === "/") { + let j = src.indexOf("\n", i); + if (j < 0) j = n; + code.fill(0, i, j); + i = j; + } else if (c === "/" && src[i + 1] === "*") { + let depth = 1; + let j = i + 2; + while (j < n && depth > 0) { + if (src.startsWith("/*", j)) { + depth += 1; + j += 2; + } else if (src.startsWith("*/", j)) { + depth -= 1; + j += 2; + } else { + j += 1; + } + } + code.fill(0, i, j); + i = j; + } else if ((c === "r" || c === "b") && (i === 0 || !IDENT_CHAR.test(src[i - 1])) && rawStringAt(src, i)) { + const { end } = rawStringAt(src, i); + code.fill(0, i, end); + i = end; + } else if (c === '"') { + let j = i + 1; + while (j < n && src[j] !== '"') j += src[j] === "\\" ? 2 : 1; + j = Math.min(j + 1, n); + code.fill(0, i, j); + i = j; + } else if (c === "'") { + const end = charLiteralEnd(src, i); + if (end > 0) code.fill(0, i, end); + i = end > 0 ? end : i + 1; + } else { + i += 1; + } + } + return code; +} + +function rawStringAt(src, i) { + RAW_STRING.lastIndex = i; + const m = RAW_STRING.exec(src); + if (!m) return null; + const closer = `"${m[1]}`; + const found = src.indexOf(closer, i + m[0].length); + return { end: found < 0 ? src.length : found + closer.length }; +} + +/** End index of a char literal starting at `i`, or -1 when the quote opens a lifetime or label. */ +function charLiteralEnd(src, i) { + if (src[i + 1] === "\\") { + const close = src.indexOf("'", i + 3); + return close < 0 ? -1 : close + 1; + } + const width = src.codePointAt(i + 1) > 0xffff ? 2 : 1; + return src[i + 1 + width] === "'" && src[i + 1] !== "'" ? i + 2 + width : -1; +} + +/** `src` with strings, char literals and comments blanked to spaces, newlines kept. */ +function skeleton(src, code) { + const parts = []; + let i = 0; + while (i < src.length) { + const keep = code[i]; + let j = i; + while (j < src.length && code[j] === keep) j += 1; + parts.push(keep ? src.slice(i, j) : src.slice(i, j).replace(/[^\n]/g, " ")); + i = j; + } + return parts.join(""); +} + +function matchBrace(skel, open) { + let depth = 0; + for (let i = open; i < skel.length; i += 1) { + if (skel[i] === "{") depth += 1; + else if (skel[i] === "}" && --depth === 0) return i; + } + return -1; +} + +function testFileName(stem, modName) { + if (modName === "tests") return `${stem}_tests.rs`; + if (modName.endsWith("_tests")) return `${stem}_${modName}.rs`; + return `${stem}_${modName}_tests.rs`; +} + +function dedent(body, bodyStart, code, strip) { + const pad = " ".repeat(strip); + let offset = bodyStart; + return body + .split("\n") + .map((line) => { + const inCode = code[offset] === 1; + offset += line.length + 1; + if (!inCode) return line; + if (line.startsWith(pad)) return line.slice(strip); + return strip === 4 && line.startsWith("\t") ? line.slice(1) : line; + }) + .join("\n"); +} + +// Crate roots and `mod.rs` own their directory; any other file `foo.rs` owns `foo/`. +const MOD_RS_STEMS = new Set(["mod", "lib", "main"]); + +/** + * Externalize every inline test module in `src`. + * + * `stem` is the source file's name without `.rs`; `taken` holds sibling file + * names that already exist. A module nested in other inline modules (the + * `mod imp { ... }` wrapper around platform code) is written to the directory + * rustc implies for it (`foo/imp/` for `foo.rs`), so its `#[path]` is a bare + * file name. `subdir` puts a top-level module's file in a subdirectory of the + * source file's own, which a crate root in `src/bin/` needs: Cargo builds any + * `.rs` file placed directly there as a binary. + * + * Returns the rewritten source, the moved bodies + * (`{ name, fileName, relDir, body, line }`, `relDir` relative to the source + * file) and `skipped` modules that need a human. + */ +export function externalizeSource(src, stem, taken = new Set(), { subdir = "" } = {}) { + const code = codeMask(src); + const skel = skeleton(src, code); + const lines = src.split("\n"); + const lineStarts = []; + let at = 0; + for (const line of lines) { + lineStarts.push(at); + at += line.length + 1; + } + const lineOf = (index) => { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= index) lo = mid; + else hi = mid - 1; + } + return lo; + }; + + const moves = []; + const skipped = []; + const edits = []; + const regions = []; + const used = new Set(taken); + + const inlineMods = [...skel.matchAll(MOD_OPEN)].map((m) => { + const open = m.index + m[0].length - 1; + return { m, open, close: matchBrace(skel, open) }; + }); + const enclosing = (index) => inlineMods.filter((o) => o.open < index && o.close > index); + + // Collect the test modules first, then move the top-level ones before the nested. + const candidates = []; + for (const { m, open, close } of inlineMods) { + const line = lineOf(m.index); + // Walk up over the attributes and comments that belong to this `mod`, + // joining a multi-line attribute into one logical entry for the cfg check. + const attrs = []; + const logical = []; + let first = line; + for (let k = line - 1; k >= 0; k -= 1) { + const text = lines[k].trim(); + if (text.startsWith("//")) { + attrs.unshift(lines[k]); + first = k; + } else if (text.startsWith("#[") || text.endsWith("]")) { + let start = k; + while (start > 0 && !lines[start].trim().startsWith("#[")) start -= 1; + if (!lines[start].trim().startsWith("#[")) break; + attrs.unshift(...lines.slice(start, k + 1)); + logical.unshift(lines.slice(start, k + 1).map((l) => l.trim()).join(" ")); + first = start; + k = start; + } else { + break; + } + } + if (logical.some((a) => CFG_TEST_ATTR.test(a) && !CFG_NOT_TEST.test(a))) { + candidates.push({ m, open, close, line, attrs, first }); + } + } + candidates.sort((a, b) => (a.m[1] === "" ? 0 : 1) - (b.m[1] === "" ? 0 : 1) || a.m.index - b.m.index); + + for (const { m, open, close, line, attrs, first } of candidates) { + const indent = m[1]; + if (regions.some(([from, to]) => m.index > from && m.index < to)) continue; + const reject = (reason) => skipped.push({ line: line + 1, reason: `\`mod ${m[3]}\`: ${reason}` }); + const chain = enclosing(m.index); + if (indent !== "" && (indent.length !== 4 * chain.length || chain.some((o) => o.close < 0))) { + reject("nested somewhere other than directly inside inline modules; move it by hand"); + continue; + } + if (close < 0) { + reject("unbalanced braces"); + continue; + } + const openEol = src.indexOf("\n", open); + const closeLineStart = src.lastIndexOf("\n", close - 1) + 1; + let closeEol = src.indexOf("\n", close); + if (closeEol < 0) closeEol = src.length; + if (src.slice(open + 1, openEol).trim() !== "" || src.slice(closeLineStart, close).trim() !== "") { + reject("code shares a line with a brace"); + continue; + } + if (src.slice(close + 1, closeEol).trim() !== "") { + reject("code follows the closing brace"); + continue; + } + const bodyStart = openEol + 1; + if (closeLineStart <= bodyStart || src.slice(bodyStart, closeLineStart).trim() === "") { + reject("empty module; delete it"); + continue; + } + const bodySkel = skel.slice(bodyStart, closeLineStart); + if (/#\[path\s*=/.test(bodySkel) || /^[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+\w+[ \t]*;/m.test(bodySkel)) { + reject("declares out-of-line modules whose paths would shift"); + continue; + } + let fileName = testFileName(stem, m[3]); + // Two wrappers can each hold a `tests`; the second is named after its wrapper. + if (used.has(fileName) && chain.length > 0) { + fileName = testFileName(`${stem}_${chain[chain.length - 1].m[3]}`, m[3]); + } + if (used.has(fileName)) { + reject(`${fileName} already exists`); + continue; + } + used.add(fileName); + + // Where rustc looks for the file: beside the source file, or, inside inline + // modules, in the directories those modules imply (`foo/imp/` for `foo.rs`). + // The directories must hold the file: a `..` through a missing one does not resolve. + const ownsDir = MOD_RS_STEMS.has(stem) || subdir !== ""; + const relDir = + chain.length === 0 + ? subdir + : [...(ownsDir ? [] : [stem]), ...chain.map((o) => o.m[3])].join("/"); + const prefix = chain.length === 0 && subdir ? `${subdir}/` : ""; + let body = dedent(src.slice(bodyStart, closeLineStart), bodyStart, code, indent.length + 4).replace(/^\n+/, ""); + body = `${body.replace(/\s+$/, "")}\n`; + const declaration = [...attrs, `${indent}#[path = "${prefix}${fileName}"]`, `${indent}${m[2]};`].join("\n"); + edits.push({ + start: lineStarts[first], + end: closeEol < src.length ? closeEol + 1 : closeEol, + text: `${declaration}\n`, + }); + regions.push([m.index, close]); + moves.push({ name: m[3], fileName, relDir, body, line: line + 1 }); + } + + let out = src; + for (const e of edits.sort((a, b) => b.start - a.start)) { + out = out.slice(0, e.start) + e.text + out.slice(e.end); + } + return { source: out, moves, skipped }; +} + +function editionFor(file, root) { + const rootManifest = path.join(root, "Cargo.toml"); + for (let dir = path.dirname(file); ; dir = path.dirname(dir)) { + const manifest = path.join(dir, "Cargo.toml"); + if (fs.existsSync(manifest)) { + const toml = fs.readFileSync(manifest, "utf8"); + const own = toml.match(/^edition\s*=\s*"(\d{4})"/m); + if (own) return own[1]; + if (/^edition\.workspace\s*=\s*true/m.test(toml) && fs.existsSync(rootManifest)) { + const shared = fs.readFileSync(rootManifest, "utf8").match(/^edition\s*=\s*"(\d{4})"/m); + if (shared) return shared[1]; + } + } + if (dir === root || dir === path.dirname(dir)) return "2021"; + } +} + +function trackedRustFiles(root) { + const out = execFileSync("git", ["-C", root, "ls-files", "-z", "--", "*.rs"], { + encoding: "utf8", + maxBuffer: 1 << 28, + }); + return out + .split("\0") + .filter(Boolean) + .filter((file) => !file.split("/").slice(0, -1).some((dir) => SKIPPED_DIRS.has(dir))); +} + +function formatFiles(files, root) { + const byEdition = new Map(); + for (const file of files) { + const edition = editionFor(file, root); + byEdition.set(edition, [...(byEdition.get(edition) ?? []), file]); + } + for (const [edition, group] of byEdition) { + try { + execFileSync("rustfmt", ["--edition", edition, ...group], { stdio: "pipe" }); + } catch (error) { + console.warn(` rustfmt (edition ${edition}) failed: ${String(error.stderr ?? error.message).split("\n")[0]}`); + } + } +} + +function run(roots, { write, fmt }) { + let movedTotal = 0; + let manual = 0; + for (const rootArg of roots) { + const root = path.resolve(rootArg); + const written = []; + let moved = 0; + for (const rel of trackedRustFiles(root)) { + const file = path.join(root, rel); + if (!fs.existsSync(file)) continue; // deleted in the working tree, not yet staged + const src = fs.readFileSync(file, "utf8"); + if (!/#\[cfg\([^\n]*\btest\b/.test(src) && !/\]\s*\n\s*(?:pub[^\n]*\s)?mod\s/.test(src)) continue; + const base = path.basename(file); + if (base.endsWith("_tests.rs") || TEST_FILE_NAMES.has(base)) { + if (/^[ \t]*#\[cfg\(test\)\]\s*\n\s*mod\s+\w+\s*\{/m.test(src)) { + manual += 1; + console.log(`manual ${rel}: inline module inside a test file`); + } + continue; + } + const dir = path.dirname(file); + const stem = base.replace(/\.rs$/, ""); + // A file directly in `src/bin/` is a binary; its tests need a directory of their own. + const isBinRoot = path.basename(dir) === "bin" && path.basename(path.dirname(dir)) === "src"; + const taken = new Set(fs.readdirSync(dir)); + const result = externalizeSource(src, stem, taken, { subdir: isBinRoot ? stem : "" }); + for (const s of result.skipped) { + manual += 1; + console.log(`manual ${rel}:${s.line}: ${s.reason}`); + } + if (result.moves.length === 0) continue; + moved += result.moves.length; + if (write) { + for (const move of result.moves) { + const target = path.join(dir, move.relDir, move.fileName); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, move.body); + written.push(target); + } + fs.writeFileSync(file, result.source); + } else { + for (const move of result.moves) console.log(`move ${rel}:${move.line} mod ${move.name} -> ${move.fileName}`); + } + } + if (write && fmt && written.length > 0) formatFiles(written, root); + console.log(`${root}: ${moved} inline test module(s) ${write ? "moved" : "movable"}`); + movedTotal += moved; + } + if (!write && movedTotal > 0) console.log("dry run; pass --write to apply"); + return write ? manual : manual + movedTotal; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const roots = args.filter((a) => !a.startsWith("--")); + if (roots.length === 0) { + console.error("usage: externalize-inline-tests.mjs ... [--write] [--no-fmt]"); + process.exit(2); + } + const remaining = run(roots, { write: args.includes("--write"), fmt: !args.includes("--no-fmt") }); + process.exit(remaining > 0 ? 1 : 0); +} diff --git a/scripts/test-rust-with-mock.sh b/scripts/test-rust-with-mock.sh index c604cb58186..7bea2f0c52c 100755 --- a/scripts/test-rust-with-mock.sh +++ b/scripts/test-rust-with-mock.sh @@ -205,6 +205,7 @@ run_raw_coverage_modules() { # path. These groups verify the host-to-module round trip, so inject the # pinned connector only for their processes. if { [ "$module" = "composio_credentials_state_raw_coverage_e2e" ] || + [ "$module" = "composio_tools_direct_raw_coverage_e2e" ] || [ "$module" = "tools_composio_large_round25_raw_coverage_e2e" ]; } && [ -z "${TINYCONNECTORS_TEST_MODULE:-}" ]; then TINYCONNECTORS_TEST_MODULE="$connectors_module" \ diff --git a/tests/fixtures/session_compat/image_user.golden.json b/tests/fixtures/session_compat/image_user.golden.json index 1c74a5075d9..a5f9eb1861f 100644 --- a/tests/fixtures/session_compat/image_user.golden.json +++ b/tests/fixtures/session_compat/image_user.golden.json @@ -95,7 +95,13 @@ "user": { "content": [ { - "text": "Current Date & Time: \n\nlook[OH_IMAGE:data:image/png;base64,iVBORw0KGgo=]please" + "text": "Current Date & Time: \n\nlook" + }, + { + "text": "[image omitted: not supported by the target model]" + }, + { + "text": "please" } ] } diff --git a/tests/raw_coverage/composio_tools_direct_raw_coverage_e2e.rs b/tests/raw_coverage/composio_tools_direct_raw_coverage_e2e.rs index 7b318cc7fc2..375a3f9dd39 100644 --- a/tests/raw_coverage/composio_tools_direct_raw_coverage_e2e.rs +++ b/tests/raw_coverage/composio_tools_direct_raw_coverage_e2e.rs @@ -13,8 +13,8 @@ use axum::routing::any; use axum::{Json, Router}; use serde_json::{json, Value}; -use openhuman_core::integrations::composio::client::direct_list_connections; -use openhuman_core::tools::DirectComposioClient; +use openhuman_core::config::Config; +use openhuman_core::integrations::composio::client::{direct_list_connections, DirectCredential}; #[derive(Clone, Default)] struct MockState { @@ -32,13 +32,21 @@ struct RecordedRequest { #[tokio::test] async fn direct_composio_client_uses_loopback_for_connected_accounts() { + // The read runs in the connector module, which is process-global. + let _module = crate::CONNECTOR_MODULE_LOCK.lock().await; + let dir = tempfile::tempdir().expect("tempdir"); + let config = Config { + workspace_dir: dir.path().to_path_buf(), + config_path: dir.path().join("config.toml"), + ..Config::default() + }; let state = MockState::default(); let app = Router::new() .fallback(any(composio_direct_handler)) .with_state(state.clone()); let base = start_loopback(app).await; let tool = Arc::new( - DirectComposioClient::new_with_base_urls_for_loopback( + DirectCredential::new_with_base_urls_for_loopback( " ck_round16 ", format!("{base}/api/v2"), format!("{base}/api/v3"), @@ -46,7 +54,7 @@ async fn direct_composio_client_uses_loopback_for_connected_accounts() { .expect("loopback direct client"), ); - let mapped = direct_list_connections(&tool) + let mapped = direct_list_connections(&config, &tool) .await .expect("mapped connected accounts"); assert_eq!(mapped.connections.len(), 4); diff --git a/vendor/motosan-ai-oauth/CLAUDE.md b/vendor/motosan-ai-oauth/CLAUDE.md new file mode 100644 index 00000000000..3ccd9f59512 --- /dev/null +++ b/vendor/motosan-ai-oauth/CLAUDE.md @@ -0,0 +1,25 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. + +## Tests live in `*_tests.rs` files + +- Unit tests are never inline. Do not write a `#[cfg(test)] mod tests { ... }` + block in a source file. Put the tests in a sibling `_tests.rs` + (`mod_tests.rs` beside a `mod.rs`, `lib_tests.rs` beside `lib.rs`) and declare + it at the bottom of the module: + + ```rust + #[cfg(test)] + #[path = "foo_tests.rs"] + mod tests; + ``` + +- The test file starts with `use super::*;` and carries no `#[cfg(test)]` of its + own. It is still a child module, so it reaches private items exactly as an + inline module did. +- Name test files `_tests.rs`; a second group for the same module is + `__tests.rs`. Never `test.rs`, `tests.rs` or `_test.rs`. +- Integration tests stay in the crate's `tests/` directory. +- OpenHuman's `scripts/externalize-inline-tests.mjs --write` moves + inline test modules out mechanically; without `--write` it only reports. diff --git a/vendor/motosan-ai-oauth/Cargo.lock b/vendor/motosan-ai-oauth/Cargo.lock new file mode 100644 index 00000000000..025c89e6128 --- /dev/null +++ b/vendor/motosan-ai-oauth/Cargo.lock @@ -0,0 +1,1652 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.5", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "motosan-ai-oauth" +version = "0.2.1" +dependencies = [ + "base64 0.22.1", + "mockito", + "percent-encoding", + "rand 0.9.5", + "reqwest", + "serde", + "sha2", + "thiserror", + "tokio", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/vendor/motosan-ai-oauth/src/exchange.rs b/vendor/motosan-ai-oauth/src/exchange.rs index 4a4813fec13..a1c9222bbac 100644 --- a/vendor/motosan-ai-oauth/src/exchange.rs +++ b/vendor/motosan-ai-oauth/src/exchange.rs @@ -109,124 +109,5 @@ pub async fn refresh_token(config: &OAuthConfig, refresh_token: &str) -> Result< } #[cfg(test)] -mod tests { - use super::*; - use crate::TokenBodyFormat; - use mockito::Matcher; - - #[tokio::test] - async fn exchange_code_omits_state_for_random_strategy_form_body() { - let mut server = mockito::Server::new_async().await; - let token_url: &'static str = Box::leak(format!("{}/token", server.url()).into_boxed_str()); - - let mock = server - .mock("POST", "/token") - .match_header( - "content-type", - Matcher::Regex("application/x-www-form-urlencoded".into()), - ) - .match_body(Matcher::Exact( - "grant_type=authorization_code&code=AUTHCODE&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb&code_verifier=VERIFIER&client_id=test-client" - .into(), - )) - .with_status(200) - .with_body(r#"{"access_token":"AT","refresh_token":"RT","expires_in":3600}"#) - .create_async() - .await; - - let cfg = crate::OAuthConfig { - client_id: "test-client", - client_secret: None, - auth_url: "https://unused/", - token_url, - scopes: &[], - redirect_port: None, - callback_path: "/auth/callback", - redirect_uri_host: "127.0.0.1", - token_body: TokenBodyFormat::Form, - extra_auth_params: &[], - state_strategy: crate::StateStrategy::Random, - }; - let token = exchange_code(&cfg, "AUTHCODE", "STATE", "VERIFIER", "http://127.0.0.1/cb") - .await - .expect("ok"); - assert_eq!(token.access_token, "AT"); - mock.assert_async().await; - } - - #[tokio::test] - async fn exchange_code_omits_state_for_random_strategy_json_body() { - let mut server = mockito::Server::new_async().await; - let token_url: &'static str = Box::leak(format!("{}/token", server.url()).into_boxed_str()); - - let mock = server - .mock("POST", "/token") - .match_header("content-type", Matcher::Regex("application/json".into())) - // Matcher::JsonString compares parsed JSON, so key order in the - // serialized HashMap does not matter. - .match_body(Matcher::JsonString( - r#"{"grant_type":"authorization_code","code":"AUTHCODE","redirect_uri":"http://127.0.0.1/cb","code_verifier":"VERIFIER","client_id":"test-client"}"# - .into(), - )) - .with_status(200) - .with_body(r#"{"access_token":"AT","refresh_token":"RT","expires_in":3600}"#) - .create_async() - .await; - - let cfg = crate::OAuthConfig { - client_id: "test-client", - client_secret: None, - auth_url: "https://unused/", - token_url, - scopes: &[], - redirect_port: None, - callback_path: "/auth/callback", - redirect_uri_host: "127.0.0.1", - token_body: TokenBodyFormat::Json, - extra_auth_params: &[], - state_strategy: crate::StateStrategy::Random, - }; - let token = exchange_code(&cfg, "AUTHCODE", "STATE", "VERIFIER", "http://127.0.0.1/cb") - .await - .expect("ok"); - assert_eq!(token.access_token, "AT"); - mock.assert_async().await; - } - - #[tokio::test] - async fn exchange_code_sends_state_for_equals_verifier_strategy_json_body() { - let mut server = mockito::Server::new_async().await; - let token_url: &'static str = Box::leak(format!("{}/token", server.url()).into_boxed_str()); - - let mock = server - .mock("POST", "/token") - .match_header("content-type", Matcher::Regex("application/json".into())) - .match_body(Matcher::JsonString( - r#"{"grant_type":"authorization_code","code":"AUTHCODE","state":"STATE","redirect_uri":"http://127.0.0.1/cb","code_verifier":"VERIFIER","client_id":"test-client"}"# - .into(), - )) - .with_status(200) - .with_body(r#"{"access_token":"AT","refresh_token":"RT","expires_in":3600}"#) - .create_async() - .await; - - let cfg = crate::OAuthConfig { - client_id: "test-client", - client_secret: None, - auth_url: "https://unused/", - token_url, - scopes: &[], - redirect_port: None, - callback_path: "/auth/callback", - redirect_uri_host: "127.0.0.1", - token_body: TokenBodyFormat::Json, - extra_auth_params: &[], - state_strategy: crate::StateStrategy::EqualsVerifier, - }; - let token = exchange_code(&cfg, "AUTHCODE", "STATE", "VERIFIER", "http://127.0.0.1/cb") - .await - .expect("ok"); - assert_eq!(token.access_token, "AT"); - mock.assert_async().await; - } -} +#[path = "exchange_tests.rs"] +mod tests; diff --git a/vendor/motosan-ai-oauth/src/exchange_tests.rs b/vendor/motosan-ai-oauth/src/exchange_tests.rs new file mode 100644 index 00000000000..63a4117e1a0 --- /dev/null +++ b/vendor/motosan-ai-oauth/src/exchange_tests.rs @@ -0,0 +1,119 @@ +use super::*; +use crate::TokenBodyFormat; +use mockito::Matcher; + +#[tokio::test] +async fn exchange_code_omits_state_for_random_strategy_form_body() { + let mut server = mockito::Server::new_async().await; + let token_url: &'static str = Box::leak(format!("{}/token", server.url()).into_boxed_str()); + + let mock = server + .mock("POST", "/token") + .match_header( + "content-type", + Matcher::Regex("application/x-www-form-urlencoded".into()), + ) + .match_body(Matcher::Exact( + "grant_type=authorization_code&code=AUTHCODE&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb&code_verifier=VERIFIER&client_id=test-client" + .into(), + )) + .with_status(200) + .with_body(r#"{"access_token":"AT","refresh_token":"RT","expires_in":3600}"#) + .create_async() + .await; + + let cfg = crate::OAuthConfig { + client_id: "test-client", + client_secret: None, + auth_url: "https://unused/", + token_url, + scopes: &[], + redirect_port: None, + callback_path: "/auth/callback", + redirect_uri_host: "127.0.0.1", + token_body: TokenBodyFormat::Form, + extra_auth_params: &[], + state_strategy: crate::StateStrategy::Random, + }; + let token = exchange_code(&cfg, "AUTHCODE", "STATE", "VERIFIER", "http://127.0.0.1/cb") + .await + .expect("ok"); + assert_eq!(token.access_token, "AT"); + mock.assert_async().await; +} + +#[tokio::test] +async fn exchange_code_omits_state_for_random_strategy_json_body() { + let mut server = mockito::Server::new_async().await; + let token_url: &'static str = Box::leak(format!("{}/token", server.url()).into_boxed_str()); + + let mock = server + .mock("POST", "/token") + .match_header("content-type", Matcher::Regex("application/json".into())) + // Matcher::JsonString compares parsed JSON, so key order in the + // serialized HashMap does not matter. + .match_body(Matcher::JsonString( + r#"{"grant_type":"authorization_code","code":"AUTHCODE","redirect_uri":"http://127.0.0.1/cb","code_verifier":"VERIFIER","client_id":"test-client"}"# + .into(), + )) + .with_status(200) + .with_body(r#"{"access_token":"AT","refresh_token":"RT","expires_in":3600}"#) + .create_async() + .await; + + let cfg = crate::OAuthConfig { + client_id: "test-client", + client_secret: None, + auth_url: "https://unused/", + token_url, + scopes: &[], + redirect_port: None, + callback_path: "/auth/callback", + redirect_uri_host: "127.0.0.1", + token_body: TokenBodyFormat::Json, + extra_auth_params: &[], + state_strategy: crate::StateStrategy::Random, + }; + let token = exchange_code(&cfg, "AUTHCODE", "STATE", "VERIFIER", "http://127.0.0.1/cb") + .await + .expect("ok"); + assert_eq!(token.access_token, "AT"); + mock.assert_async().await; +} + +#[tokio::test] +async fn exchange_code_sends_state_for_equals_verifier_strategy_json_body() { + let mut server = mockito::Server::new_async().await; + let token_url: &'static str = Box::leak(format!("{}/token", server.url()).into_boxed_str()); + + let mock = server + .mock("POST", "/token") + .match_header("content-type", Matcher::Regex("application/json".into())) + .match_body(Matcher::JsonString( + r#"{"grant_type":"authorization_code","code":"AUTHCODE","state":"STATE","redirect_uri":"http://127.0.0.1/cb","code_verifier":"VERIFIER","client_id":"test-client"}"# + .into(), + )) + .with_status(200) + .with_body(r#"{"access_token":"AT","refresh_token":"RT","expires_in":3600}"#) + .create_async() + .await; + + let cfg = crate::OAuthConfig { + client_id: "test-client", + client_secret: None, + auth_url: "https://unused/", + token_url, + scopes: &[], + redirect_port: None, + callback_path: "/auth/callback", + redirect_uri_host: "127.0.0.1", + token_body: TokenBodyFormat::Json, + extra_auth_params: &[], + state_strategy: crate::StateStrategy::EqualsVerifier, + }; + let token = exchange_code(&cfg, "AUTHCODE", "STATE", "VERIFIER", "http://127.0.0.1/cb") + .await + .expect("ok"); + assert_eq!(token.access_token, "AT"); + mock.assert_async().await; +} diff --git a/vendor/motosan-ai-oauth/src/lib.rs b/vendor/motosan-ai-oauth/src/lib.rs index f7c277ac2ed..8751f4ddee5 100644 --- a/vendor/motosan-ai-oauth/src/lib.rs +++ b/vendor/motosan-ai-oauth/src/lib.rs @@ -152,123 +152,5 @@ fn open_browser(url: &str) -> std::io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - fn dummy_config() -> OAuthConfig { - OAuthConfig { - client_id: "test-client", - client_secret: None, - auth_url: "https://auth.example.com/oauth/authorize", - token_url: "https://auth.example.com/oauth/token", - scopes: &["openid", "profile"], - redirect_port: None, - callback_path: "/auth/callback", - redirect_uri_host: "127.0.0.1", - token_body: TokenBodyFormat::Form, - extra_auth_params: &[], - state_strategy: StateStrategy::Random, - } - } - - #[test] - fn build_auth_url_contains_required_params() { - let config = dummy_config(); - let url = build_auth_url( - &config, - "challenge123", - "state456", - "http://127.0.0.1:9999/auth/callback", - ); - assert!(url.contains("client_id=test-client")); - assert!(url.contains("response_type=code")); - assert!(url.contains("code_challenge=challenge123")); - assert!(url.contains("code_challenge_method=S256")); - assert!(url.contains("state=state456")); - assert!(url.contains("redirect_uri=")); - assert!(url.contains("scope=")); - } - - #[test] - fn build_auth_url_includes_scopes_joined() { - let config = dummy_config(); - let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); - assert!(url.contains("scope=openid+profile") || url.contains("scope=openid%20profile")); - } - - #[test] - fn build_auth_url_parses_as_valid_url() { - let config = dummy_config(); - let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); - reqwest::Url::parse(&url).expect("auth URL must be valid"); - } - - #[test] - fn build_auth_url_appends_extra_auth_params() { - let config = OAuthConfig { - extra_auth_params: &[("foo", "bar"), ("baz", "qux")], - ..dummy_config() - }; - let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); - assert!(url.contains("foo=bar")); - assert!(url.contains("baz=qux")); - } - - #[test] - fn build_auth_url_no_longer_hardcodes_access_type() { - // Empty extra_auth_params should produce no access_type query pair. - let config = OAuthConfig { - extra_auth_params: &[], - ..dummy_config() - }; - let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); - assert!(!url.contains("access_type=")); - } - - #[test] - fn build_auth_url_uses_redirect_uri_host_in_uri() { - // The redirect_uri is built by login(); we test the helper that constructs it. - // The host substring comes from config.redirect_uri_host, not from the bind addr. - let uri = build_redirect_uri("localhost", 53692, "/callback"); - assert_eq!(uri, "http://localhost:53692/callback"); - - let uri = build_redirect_uri("127.0.0.1", 1455, "/auth/callback"); - assert_eq!(uri, "http://127.0.0.1:1455/auth/callback"); - } - - #[test] - fn token_is_expired_when_issued_at_zero() { - let token = Token { - access_token: "a".into(), - refresh_token: "r".into(), - id_token: None, - expires_in: 3600, - issued_at: 0, - }; - assert!(token.is_expired()); - } - - #[test] - fn token_not_expired_when_just_issued() { - let token = Token { - access_token: "a".into(), - refresh_token: "r".into(), - id_token: None, - expires_in: 3600, - issued_at: unix_now(), - }; - assert!(!token.is_expired()); - } - - #[test] - fn token_within_buffer_is_considered_expired() { - let token = Token { - access_token: "a".into(), - refresh_token: "r".into(), - id_token: None, - expires_in: 30, // expires in 30s, within the 60s buffer - issued_at: unix_now(), - }; - assert!(token.is_expired()); - } -} +#[path = "lib_tests.rs"] +mod tests; diff --git a/vendor/motosan-ai-oauth/src/lib_tests.rs b/vendor/motosan-ai-oauth/src/lib_tests.rs new file mode 100644 index 00000000000..40af61dd29f --- /dev/null +++ b/vendor/motosan-ai-oauth/src/lib_tests.rs @@ -0,0 +1,118 @@ +use super::*; + +fn dummy_config() -> OAuthConfig { + OAuthConfig { + client_id: "test-client", + client_secret: None, + auth_url: "https://auth.example.com/oauth/authorize", + token_url: "https://auth.example.com/oauth/token", + scopes: &["openid", "profile"], + redirect_port: None, + callback_path: "/auth/callback", + redirect_uri_host: "127.0.0.1", + token_body: TokenBodyFormat::Form, + extra_auth_params: &[], + state_strategy: StateStrategy::Random, + } +} + +#[test] +fn build_auth_url_contains_required_params() { + let config = dummy_config(); + let url = build_auth_url( + &config, + "challenge123", + "state456", + "http://127.0.0.1:9999/auth/callback", + ); + assert!(url.contains("client_id=test-client")); + assert!(url.contains("response_type=code")); + assert!(url.contains("code_challenge=challenge123")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("state=state456")); + assert!(url.contains("redirect_uri=")); + assert!(url.contains("scope=")); +} + +#[test] +fn build_auth_url_includes_scopes_joined() { + let config = dummy_config(); + let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); + assert!(url.contains("scope=openid+profile") || url.contains("scope=openid%20profile")); +} + +#[test] +fn build_auth_url_parses_as_valid_url() { + let config = dummy_config(); + let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); + reqwest::Url::parse(&url).expect("auth URL must be valid"); +} + +#[test] +fn build_auth_url_appends_extra_auth_params() { + let config = OAuthConfig { + extra_auth_params: &[("foo", "bar"), ("baz", "qux")], + ..dummy_config() + }; + let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); + assert!(url.contains("foo=bar")); + assert!(url.contains("baz=qux")); +} + +#[test] +fn build_auth_url_no_longer_hardcodes_access_type() { + // Empty extra_auth_params should produce no access_type query pair. + let config = OAuthConfig { + extra_auth_params: &[], + ..dummy_config() + }; + let url = build_auth_url(&config, "c", "s", "http://127.0.0.1:1234/auth/callback"); + assert!(!url.contains("access_type=")); +} + +#[test] +fn build_auth_url_uses_redirect_uri_host_in_uri() { + // The redirect_uri is built by login(); we test the helper that constructs it. + // The host substring comes from config.redirect_uri_host, not from the bind addr. + let uri = build_redirect_uri("localhost", 53692, "/callback"); + assert_eq!(uri, "http://localhost:53692/callback"); + + let uri = build_redirect_uri("127.0.0.1", 1455, "/auth/callback"); + assert_eq!(uri, "http://127.0.0.1:1455/auth/callback"); +} + +#[test] +fn token_is_expired_when_issued_at_zero() { + let token = Token { + access_token: "a".into(), + refresh_token: "r".into(), + id_token: None, + expires_in: 3600, + issued_at: 0, + }; + assert!(token.is_expired()); +} + +#[test] +fn token_not_expired_when_just_issued() { + let token = Token { + access_token: "a".into(), + refresh_token: "r".into(), + id_token: None, + expires_in: 3600, + issued_at: unix_now(), + }; + assert!(!token.is_expired()); +} + +#[test] +fn token_within_buffer_is_considered_expired() { + let token = Token { + access_token: "a".into(), + refresh_token: "r".into(), + id_token: None, + expires_in: 30, // expires in 30s, within the 60s buffer + issued_at: unix_now(), + }; + assert!(token.is_expired()); +} diff --git a/vendor/motosan-ai-oauth/src/pkce.rs b/vendor/motosan-ai-oauth/src/pkce.rs index b087415553f..df7fc3469c8 100644 --- a/vendor/motosan-ai-oauth/src/pkce.rs +++ b/vendor/motosan-ai-oauth/src/pkce.rs @@ -24,48 +24,5 @@ impl Pkce { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn verifier_is_base64url_no_pad() { - let pkce = Pkce::generate(); - assert!( - pkce.verifier - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), - "verifier contains non-base64url chars: {}", - pkce.verifier - ); - assert!( - !pkce.verifier.contains('='), - "verifier must not have padding" - ); - assert_eq!(pkce.verifier.len(), 86); - } - - #[test] - fn challenge_matches_s256_of_verifier() { - let pkce = Pkce::generate(); - let hash = Sha256::digest(pkce.verifier.as_bytes()); - let expected = URL_SAFE_NO_PAD.encode(hash); - assert_eq!(pkce.challenge, expected); - } - - #[test] - fn challenge_is_base64url_no_pad() { - let pkce = Pkce::generate(); - assert!(pkce - .challenge - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); - assert!(!pkce.challenge.contains('=')); - } - - #[test] - fn each_generate_is_unique() { - let a = Pkce::generate(); - let b = Pkce::generate(); - assert_ne!(a.verifier, b.verifier); - } -} +#[path = "pkce_tests.rs"] +mod tests; diff --git a/vendor/motosan-ai-oauth/src/pkce_tests.rs b/vendor/motosan-ai-oauth/src/pkce_tests.rs new file mode 100644 index 00000000000..8ce33219497 --- /dev/null +++ b/vendor/motosan-ai-oauth/src/pkce_tests.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn verifier_is_base64url_no_pad() { + let pkce = Pkce::generate(); + assert!( + pkce.verifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "verifier contains non-base64url chars: {}", + pkce.verifier + ); + assert!( + !pkce.verifier.contains('='), + "verifier must not have padding" + ); + assert_eq!(pkce.verifier.len(), 86); +} + +#[test] +fn challenge_matches_s256_of_verifier() { + let pkce = Pkce::generate(); + let hash = Sha256::digest(pkce.verifier.as_bytes()); + let expected = URL_SAFE_NO_PAD.encode(hash); + assert_eq!(pkce.challenge, expected); +} + +#[test] +fn challenge_is_base64url_no_pad() { + let pkce = Pkce::generate(); + assert!(pkce + .challenge + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + assert!(!pkce.challenge.contains('=')); +} + +#[test] +fn each_generate_is_unique() { + let a = Pkce::generate(); + let b = Pkce::generate(); + assert_ne!(a.verifier, b.verifier); +} diff --git a/vendor/motosan-ai-oauth/src/providers/codex.rs b/vendor/motosan-ai-oauth/src/providers/codex.rs index 71add5a13d6..00b6ee48046 100644 --- a/vendor/motosan-ai-oauth/src/providers/codex.rs +++ b/vendor/motosan-ai-oauth/src/providers/codex.rs @@ -17,30 +17,5 @@ pub fn codex() -> OAuthConfig { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn codex_config_has_correct_client_id() { - let c = codex(); - assert_eq!(c.client_id, "app_EMoamEEZ73f0CkXaXp7hrann"); - } - - #[test] - fn codex_config_has_no_client_secret() { - let c = codex(); - assert!(c.client_secret.is_none()); - } - - #[test] - fn codex_config_redirect_port_is_1455() { - let c = codex(); - assert_eq!(c.redirect_port, Some(1455)); - } - - #[test] - fn codex_config_auth_url_is_openai() { - let c = codex(); - assert!(c.auth_url.contains("auth.openai.com")); - } -} +#[path = "codex_tests.rs"] +mod tests; diff --git a/vendor/motosan-ai-oauth/src/providers/codex_tests.rs b/vendor/motosan-ai-oauth/src/providers/codex_tests.rs new file mode 100644 index 00000000000..6889e29e153 --- /dev/null +++ b/vendor/motosan-ai-oauth/src/providers/codex_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn codex_config_has_correct_client_id() { + let c = codex(); + assert_eq!(c.client_id, "app_EMoamEEZ73f0CkXaXp7hrann"); +} + +#[test] +fn codex_config_has_no_client_secret() { + let c = codex(); + assert!(c.client_secret.is_none()); +} + +#[test] +fn codex_config_redirect_port_is_1455() { + let c = codex(); + assert_eq!(c.redirect_port, Some(1455)); +} + +#[test] +fn codex_config_auth_url_is_openai() { + let c = codex(); + assert!(c.auth_url.contains("auth.openai.com")); +} diff --git a/vendor/motosan-ai-oauth/src/server.rs b/vendor/motosan-ai-oauth/src/server.rs index f3efcfed363..58c79aed2e8 100644 --- a/vendor/motosan-ai-oauth/src/server.rs +++ b/vendor/motosan-ai-oauth/src/server.rs @@ -124,92 +124,5 @@ fn parse_callback(request: &str) -> Result<(String, String), Error> { } #[cfg(test)] -mod tests { - use super::*; - - fn get(path: &str) -> String { - format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") - } - - #[test] - fn parses_normal_callback() { - let (code, state) = parse_callback(&get("/auth/callback?code=abc123&state=xyz")).unwrap(); - assert_eq!(code, "abc123"); - assert_eq!(state, "xyz"); - } - - #[test] - fn decodes_percent_encoded_params() { - let (code, state) = - parse_callback(&get("/auth/callback?code=ab%2Bcd&state=x%3Dy")).unwrap(); - assert_eq!(code, "ab+cd"); - assert_eq!(state, "x=y"); - } - - #[test] - fn extra_params_are_ignored() { - let (code, state) = - parse_callback(&get("/auth/callback?code=c&state=s&session_state=ignored")).unwrap(); - assert_eq!(code, "c"); - assert_eq!(state, "s"); - } - - #[test] - fn missing_code_returns_error() { - let err = parse_callback(&get("/auth/callback?state=s")).unwrap_err(); - assert!(err.to_string().contains("missing code")); - } - - #[test] - fn missing_state_returns_error() { - let err = parse_callback(&get("/auth/callback?code=c")).unwrap_err(); - assert!(err.to_string().contains("missing state")); - } - - #[test] - fn non_callback_path_is_not_callback() { - assert!(!is_callback_request(&get("/favicon.ico"), "/auth/callback")); - assert!(!is_callback_request(&get("/"), "/auth/callback")); - } - - #[test] - fn callback_path_with_code_is_callback() { - assert!(is_callback_request( - &get("/auth/callback?code=abc&state=xyz"), - "/auth/callback" - )); - } - - #[test] - fn anthropic_callback_path_is_callback() { - assert!(is_callback_request( - &get("/callback?code=abc&state=xyz"), - "/callback" - )); - } - - #[test] - fn auth_callback_path_does_not_match_anthropic_request() { - // A request to /callback must not be accepted when callback_path is /auth/callback. - assert!(!is_callback_request( - &get("/callback?code=abc&state=xyz"), - "/auth/callback" - )); - } - - #[tokio::test] - async fn bind_dynamic_port_returns_nonzero_port() { - let server = bind(None).await.expect("bind should succeed"); - assert!(server.port > 0, "dynamic port should be nonzero"); - } - - #[tokio::test] - async fn bind_specific_port_returns_that_port() { - let probe = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let free_port = probe.local_addr().unwrap().port(); - drop(probe); - - let server = bind(Some(free_port)).await.expect("bind should succeed"); - assert_eq!(server.port, free_port); - } -} +#[path = "server_tests.rs"] +mod tests; diff --git a/vendor/motosan-ai-oauth/src/server_tests.rs b/vendor/motosan-ai-oauth/src/server_tests.rs new file mode 100644 index 00000000000..5994440a9a4 --- /dev/null +++ b/vendor/motosan-ai-oauth/src/server_tests.rs @@ -0,0 +1,86 @@ +use super::*; + +fn get(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn parses_normal_callback() { + let (code, state) = parse_callback(&get("/auth/callback?code=abc123&state=xyz")).unwrap(); + assert_eq!(code, "abc123"); + assert_eq!(state, "xyz"); +} + +#[test] +fn decodes_percent_encoded_params() { + let (code, state) = parse_callback(&get("/auth/callback?code=ab%2Bcd&state=x%3Dy")).unwrap(); + assert_eq!(code, "ab+cd"); + assert_eq!(state, "x=y"); +} + +#[test] +fn extra_params_are_ignored() { + let (code, state) = + parse_callback(&get("/auth/callback?code=c&state=s&session_state=ignored")).unwrap(); + assert_eq!(code, "c"); + assert_eq!(state, "s"); +} + +#[test] +fn missing_code_returns_error() { + let err = parse_callback(&get("/auth/callback?state=s")).unwrap_err(); + assert!(err.to_string().contains("missing code")); +} + +#[test] +fn missing_state_returns_error() { + let err = parse_callback(&get("/auth/callback?code=c")).unwrap_err(); + assert!(err.to_string().contains("missing state")); +} + +#[test] +fn non_callback_path_is_not_callback() { + assert!(!is_callback_request(&get("/favicon.ico"), "/auth/callback")); + assert!(!is_callback_request(&get("/"), "/auth/callback")); +} + +#[test] +fn callback_path_with_code_is_callback() { + assert!(is_callback_request( + &get("/auth/callback?code=abc&state=xyz"), + "/auth/callback" + )); +} + +#[test] +fn anthropic_callback_path_is_callback() { + assert!(is_callback_request( + &get("/callback?code=abc&state=xyz"), + "/callback" + )); +} + +#[test] +fn auth_callback_path_does_not_match_anthropic_request() { + // A request to /callback must not be accepted when callback_path is /auth/callback. + assert!(!is_callback_request( + &get("/callback?code=abc&state=xyz"), + "/auth/callback" + )); +} + +#[tokio::test] +async fn bind_dynamic_port_returns_nonzero_port() { + let server = bind(None).await.expect("bind should succeed"); + assert!(server.port > 0, "dynamic port should be nonzero"); +} + +#[tokio::test] +async fn bind_specific_port_returns_that_port() { + let probe = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let free_port = probe.local_addr().unwrap().port(); + drop(probe); + + let server = bind(Some(free_port)).await.expect("bind should succeed"); + assert_eq!(server.port, free_port); +} diff --git a/vendor/tinyagents b/vendor/tinyagents index 60ef2871266..caa0fd0ae4d 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 60ef2871266a4ef8e06e0e67ed23adba37a07344 +Subproject commit caa0fd0ae4d521a7b697979e25ec537a2494839e diff --git a/vendor/tinybox b/vendor/tinybox index 534112be46c..848d52a5003 160000 --- a/vendor/tinybox +++ b/vendor/tinybox @@ -1 +1 @@ -Subproject commit 534112be46c4a3d5e0355842cf3c8f5d388720c3 +Subproject commit 848d52a500349563ecb7bca367df91df433b8510 diff --git a/vendor/tinybus b/vendor/tinybus index ceeef8752ef..05f687039e0 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit ceeef8752ef20c3f71ec224b14341351c77e4737 +Subproject commit 05f687039e0be4f9f6f732639be248809f851d94 diff --git a/vendor/tinychannels b/vendor/tinychannels index f55b996c8c1..dd60fb6298f 160000 --- a/vendor/tinychannels +++ b/vendor/tinychannels @@ -1 +1 @@ -Subproject commit f55b996c8c15571710c0fad2b83e988d76bd5d1d +Subproject commit dd60fb6298fff88f34b2498ea8cf1082066003e2 diff --git a/vendor/tinyconnectors b/vendor/tinyconnectors index 000efcb7c49..f9b6bb26fa9 160000 --- a/vendor/tinyconnectors +++ b/vendor/tinyconnectors @@ -1 +1 @@ -Subproject commit 000efcb7c49e38297327d755ac474c3770c24fc0 +Subproject commit f9b6bb26fa9e6c88907ecb62c2be9c32e4e44c6d diff --git a/vendor/tinydocs b/vendor/tinydocs index 4f5873dd68d..64357ff03a1 160000 --- a/vendor/tinydocs +++ b/vendor/tinydocs @@ -1 +1 @@ -Subproject commit 4f5873dd68d25203a16e45e14857be9d528574f2 +Subproject commit 64357ff03a1e5bcbe21e734113bd207b31832e2b diff --git a/vendor/tinyflows b/vendor/tinyflows index 82aea10b62e..2d2fc3e98f3 160000 --- a/vendor/tinyflows +++ b/vendor/tinyflows @@ -1 +1 @@ -Subproject commit 82aea10b62e7f79ad6e31a4af71845f5f7e1d2ff +Subproject commit 2d2fc3e98f3af67c5b051ecc47c8198a9ae9d6a5 diff --git a/vendor/tinyhosts b/vendor/tinyhosts index a90c1718164..a710575b222 160000 --- a/vendor/tinyhosts +++ b/vendor/tinyhosts @@ -1 +1 @@ -Subproject commit a90c1718164915e1372b1bcd3b6fa61ecc816b02 +Subproject commit a710575b222af48339971edc7b4e2003fed372f0 diff --git a/vendor/tinyhumans-sdk b/vendor/tinyhumans-sdk index c3e50091b91..24ed21d090e 160000 --- a/vendor/tinyhumans-sdk +++ b/vendor/tinyhumans-sdk @@ -1 +1 @@ -Subproject commit c3e50091b918958bbf75f6a91bd4394b176316c5 +Subproject commit 24ed21d090eb5f3a6435ebc13bbb9af0403dc90f diff --git a/vendor/tinyjuice b/vendor/tinyjuice index 0115d61bf8e..c52d1ee5502 160000 --- a/vendor/tinyjuice +++ b/vendor/tinyjuice @@ -1 +1 @@ -Subproject commit 0115d61bf8e3fcdb0eb65fbf03f4047531447d88 +Subproject commit c52d1ee5502ee549cb121918af31a4bf1f4f185e diff --git a/vendor/tinymcp b/vendor/tinymcp index d6555e7debd..c6fa6e87190 160000 --- a/vendor/tinymcp +++ b/vendor/tinymcp @@ -1 +1 @@ -Subproject commit d6555e7debdd1f68d62ba2509decf336b4205417 +Subproject commit c6fa6e8719053a4b6002837c948954c7391ab6e1 diff --git a/vendor/tinymemory b/vendor/tinymemory index 0c0042c0ebc..b3bff2e23ca 160000 --- a/vendor/tinymemory +++ b/vendor/tinymemory @@ -1 +1 @@ -Subproject commit 0c0042c0ebc81541b472c8d8573f7e29250ca590 +Subproject commit b3bff2e23ca8450ae9d66c2ba6530f2dcaa04aaf diff --git a/vendor/tinyruntime b/vendor/tinyruntime index 527d3423c17..ea53fed9182 160000 --- a/vendor/tinyruntime +++ b/vendor/tinyruntime @@ -1 +1 @@ -Subproject commit 527d3423c17b799cc6a9fd9edff21c3968e72d78 +Subproject commit ea53fed9182c91d39b877be4161a888d109d8458 diff --git a/vendor/tinysearch b/vendor/tinysearch index a3ad9f1b554..dd35593f6fd 160000 --- a/vendor/tinysearch +++ b/vendor/tinysearch @@ -1 +1 @@ -Subproject commit a3ad9f1b5541127877ec2699b735ae98719f1de3 +Subproject commit dd35593f6fd5799edb02c875a85e9f0a461740ae diff --git a/vendor/tinyskills b/vendor/tinyskills index 9795c78fa1a..8584111195d 160000 --- a/vendor/tinyskills +++ b/vendor/tinyskills @@ -1 +1 @@ -Subproject commit 9795c78fa1a69de8431fb13f6e2cedcf86592a18 +Subproject commit 8584111195d119cc99482af6baa9ba2b50149272 diff --git a/vendor/tinyvoice b/vendor/tinyvoice index 706d3fa4f73..b1eb30c53cc 160000 --- a/vendor/tinyvoice +++ b/vendor/tinyvoice @@ -1 +1 @@ -Subproject commit 706d3fa4f73ff61b6af3587ce496dfc776b34878 +Subproject commit b1eb30c53cc6f5862893f263ce627a10ec64fc61 diff --git a/vendor/tinywallet b/vendor/tinywallet index 9d69b27dd50..2eae972965e 160000 --- a/vendor/tinywallet +++ b/vendor/tinywallet @@ -1 +1 @@ -Subproject commit 9d69b27dd50b0576ff7d5796c454b503a24738f8 +Subproject commit 2eae972965e85eafa451244d84f10181ec3d0590