From 3f3b58d8d9ce043f3fc389e6865eb7383fc0b6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Thu, 21 May 2026 00:15:19 +0900 Subject: [PATCH 01/11] fix: resolve schema mismatch in memory_tree query_global and add missing Accept header to MCP client - Rename 'window_days' to 'time_window_days' in memory_tree retrieval RPC to align with agent prompts (fixes #2252) - Add 'Accept: application/json' and 'Accept: text/event-stream' headers to McpHttpClient to resolve 406 errors with GitBooks MCP (fixes #2251) --- src/openhuman/mcp_client/client.rs | 17 +++++++++++++---- src/openhuman/memory/tree/retrieval/rpc.rs | 10 ++++++---- src/openhuman/memory/tree/retrieval/schemas.rs | 2 +- .../tools/impl/memory/tree/query_global.rs | 8 ++++---- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/openhuman/mcp_client/client.rs b/src/openhuman/mcp_client/client.rs index a3fe8b4f31..973ab75af9 100644 --- a/src/openhuman/mcp_client/client.rs +++ b/src/openhuman/mcp_client/client.rs @@ -3,7 +3,7 @@ use crate::openhuman::skills::types::ToolResult; use anyhow::Context; use base64::Engine; use parking_lot::Mutex; -use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -194,7 +194,8 @@ impl McpHttpClient { .apply_auth( self.http .post(&self.endpoint) - .header(CONTENT_TYPE, "application/json"), + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json"), true, ) .body(serde_json::to_vec(&body)?); @@ -286,6 +287,7 @@ impl McpHttpClient { .http .post(&self.endpoint) .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") .body(serde_json::to_vec(&json!({ "jsonrpc": "2.0", "id": self.next_id.fetch_add(1, Ordering::Relaxed), @@ -332,7 +334,8 @@ impl McpHttpClient { let session_id = self.state.lock().session_id.clone(); let mut request = self .apply_auth(self.http.get(&self.endpoint), false) - .header(HEADER_PROTOCOL_VERSION, protocol_version); + .header(HEADER_PROTOCOL_VERSION, protocol_version) + .header(ACCEPT, "text/event-stream"); if let Some(session_id) = session_id { request = request.header(HEADER_SESSION_ID, session_id); } @@ -480,6 +483,7 @@ impl McpHttpClient { let protocol_version = self.state.lock().negotiated_protocol_version.clone(); let session_id = self.state.lock().session_id.clone(); let mut request = self.apply_auth(request, initialize); + request = request.header(ACCEPT, "application/json"); request = request.header(HEADER_METHOD, method); if let Some(name) = name { request = request.header(HEADER_NAME, name); @@ -528,7 +532,12 @@ impl McpHttpClient { where T: for<'de> Deserialize<'de>, { - let response = self.http.get(url).send().await?; + let response = self + .http + .get(url) + .header(ACCEPT, "application/json") + .send() + .await?; let status = response.status(); let text = response.text().await?; if !status.is_success() { diff --git a/src/openhuman/memory/tree/retrieval/rpc.rs b/src/openhuman/memory/tree/retrieval/rpc.rs index 31b4dd587a..4e390f79b5 100644 --- a/src/openhuman/memory/tree/retrieval/rpc.rs +++ b/src/openhuman/memory/tree/retrieval/rpc.rs @@ -82,9 +82,9 @@ pub async fn query_source_rpc( // ── query_global ────────────────────────────────────────────────────── /// Request body for `memory_tree_query_global`. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct QueryGlobalRequest { - pub window_days: u32, + pub time_window_days: u32, } /// JSON-RPC handler body for `memory_tree_query_global`. @@ -92,7 +92,7 @@ pub async fn query_global_rpc( config: &Config, req: QueryGlobalRequest, ) -> Result, String> { - let resp = query_global(config, req.window_days) + let resp = query_global(config, req.time_window_days) .await .map_err(|e| format!("query_global: {e}"))?; let n = resp.hits.len(); @@ -410,7 +410,9 @@ mod tests { #[tokio::test] async fn query_global_rpc_returns_response_for_valid_window() { let (_tmp, cfg) = test_config(); - let req = QueryGlobalRequest { window_days: 7 }; + let req = QueryGlobalRequest { + time_window_days: 7, + }; let outcome = query_global_rpc(&cfg, req).await.unwrap(); assert!(outcome.value.hits.is_empty()); assert_eq!(outcome.logs.len(), 1); diff --git a/src/openhuman/memory/tree/retrieval/schemas.rs b/src/openhuman/memory/tree/retrieval/schemas.rs index 10b34d9427..63f5a83c80 100644 --- a/src/openhuman/memory/tree/retrieval/schemas.rs +++ b/src/openhuman/memory/tree/retrieval/schemas.rs @@ -152,7 +152,7 @@ pub fn schemas(function: &str) -> ControllerSchema { `tree_global::recap`; the returned hit carries `child_ids` pointing \ at the folded per-day summary ids for drill-down.", inputs: vec![FieldSchema { - name: "window_days", + name: "time_window_days", ty: TypeSchema::U64, comment: "Lookback window in days (e.g. 7 for weekly recap).", required: true, diff --git a/src/openhuman/tools/impl/memory/tree/query_global.rs b/src/openhuman/tools/impl/memory/tree/query_global.rs index cc19efa4c3..5f8b322c9d 100644 --- a/src/openhuman/tools/impl/memory/tree/query_global.rs +++ b/src/openhuman/tools/impl/memory/tree/query_global.rs @@ -14,7 +14,7 @@ impl Tool for MemoryTreeQueryGlobalTool { } fn description(&self) -> &str { - "Return the cross-source global digest for the last `window_days`. \ + "Return the cross-source global digest for the last `time_window_days`. \ The 7-day digest is also pre-loaded into the session context at \ start, so only call this for a different window (e.g. 30 days, \ 1 day) or to refresh after new ingest." @@ -24,13 +24,13 @@ impl Tool for MemoryTreeQueryGlobalTool { json!({ "type": "object", "properties": { - "window_days": { + "time_window_days": { "type": "integer", "minimum": 1, "description": "Lookback window in days (e.g. 7 for weekly recap)." } }, - "required": ["window_days"] + "required": ["time_window_days"] }) } @@ -41,7 +41,7 @@ impl Tool for MemoryTreeQueryGlobalTool { let cfg = config_rpc::load_config_with_timeout() .await .map_err(|e| anyhow::anyhow!("memory_tree_query_global: load config failed: {e}"))?; - let resp = retrieval::query_global(&cfg, req.window_days).await?; + let resp = retrieval::query_global(&cfg, req.time_window_days).await?; log::debug!( "[tool][memory_tree] query_global returning hits={} total={}", resp.hits.len(), From d9b7ae93e7d7d2143e482f39065c0f224cbb9c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Thu, 21 May 2026 09:59:55 +0900 Subject: [PATCH 02/11] feat(rpc): add serde alias for time_window_days in QueryGlobalRequest - Introduced a new alias `window_days` for the existing `time_window_days` field in the `QueryGlobalRequest` struct to enhance compatibility with external data sources. - This change improves the flexibility of the API by allowing clients to use either field name when sending requests. --- src/openhuman/memory/tree/retrieval/rpc.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/openhuman/memory/tree/retrieval/rpc.rs b/src/openhuman/memory/tree/retrieval/rpc.rs index ff27b2b557..6b2fee0235 100644 --- a/src/openhuman/memory/tree/retrieval/rpc.rs +++ b/src/openhuman/memory/tree/retrieval/rpc.rs @@ -84,6 +84,7 @@ pub async fn query_source_rpc( /// Request body for `memory_tree_query_global`. #[derive(Debug, Serialize, Deserialize)] pub struct QueryGlobalRequest { + #[serde(alias = "window_days")] pub time_window_days: u32, } From 972c1bb1b175247da9dd0014a5f9c316b80da42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Thu, 21 May 2026 11:01:02 +0900 Subject: [PATCH 03/11] refactor(auth): update Unix permissions import and clean up unused exports - Adjusted the import of `PermissionsExt` to be conditional on both Unix and test configurations. - Removed unused exports from `model_requirements` in `local/mod.rs` to streamline the module. - Updated HTTP client headers in `client.rs` to use a constant for `ACCEPT` instead of a hardcoded string. - Cleaned up imports in `source_file.rs` to only include necessary types. - Minor adjustments in `pairing.rs` and `secrets.rs` for consistency in Unix imports. - Updated visibility of `PROVIDERS` in `ops.rs` to be self-contained. --- src/core/auth.rs | 5 ++++- src/openhuman/inference/local/mod.rs | 1 - src/openhuman/mcp_client/client.rs | 4 ++-- src/openhuman/memory/tree/tree_source/source_file.rs | 2 +- src/openhuman/security/pairing.rs | 2 +- src/openhuman/security/secrets.rs | 1 + src/openhuman/voice/server.rs | 4 ++-- src/openhuman/webview_accounts/ops.rs | 2 +- src/openhuman/whatsapp_data/sqlite_retry.rs | 2 +- 9 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/core/auth.rs b/src/core/auth.rs index 3462b369c5..3269e8d8bb 100644 --- a/src/core/auth.rs +++ b/src/core/auth.rs @@ -38,7 +38,10 @@ use std::path::Path; use std::sync::OnceLock; #[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::os::unix::fs::OpenOptionsExt as _; + +#[cfg(all(unix, test))] +use std::os::unix::fs::PermissionsExt as _; use axum::http::{header, Method, StatusCode}; use axum::middleware::Next; diff --git a/src/openhuman/inference/local/mod.rs b/src/openhuman/inference/local/mod.rs index b86e7df291..aef33e8a98 100644 --- a/src/openhuman/inference/local/mod.rs +++ b/src/openhuman/inference/local/mod.rs @@ -37,7 +37,6 @@ pub(crate) mod model_requirements; mod ollama; mod process_util; pub(crate) mod provider; -pub(crate) use model_requirements::{evaluate_context, ContextEligibility, MIN_CONTEXT_TOKENS}; pub(crate) use ollama::{ollama_base_url, OLLAMA_BASE_URL}; pub mod service; pub(crate) mod voice_install_common; diff --git a/src/openhuman/mcp_client/client.rs b/src/openhuman/mcp_client/client.rs index a302b27959..1bd2149489 100644 --- a/src/openhuman/mcp_client/client.rs +++ b/src/openhuman/mcp_client/client.rs @@ -196,7 +196,7 @@ impl McpHttpClient { self.http .post(&self.endpoint) .header(CONTENT_TYPE, "application/json") - .header(ACCEPT, "application/json"), + .header(ACCEPT, MCP_HTTP_ACCEPT), true, ) .body(serde_json::to_vec(&body)?); @@ -486,7 +486,7 @@ impl McpHttpClient { let protocol_version = self.state.lock().negotiated_protocol_version.clone(); let session_id = self.state.lock().session_id.clone(); let mut request = self.apply_auth(request, initialize); - request = request.header(ACCEPT, "application/json"); + request = request.header(ACCEPT, MCP_HTTP_ACCEPT); request = request.header(HEADER_METHOD, method); if let Some(name) = name { request = request.header(HEADER_NAME, name); diff --git a/src/openhuman/memory/tree/tree_source/source_file.rs b/src/openhuman/memory/tree/tree_source/source_file.rs index 72f91aee4c..8694e96e28 100644 --- a/src/openhuman/memory/tree/tree_source/source_file.rs +++ b/src/openhuman/memory/tree/tree_source/source_file.rs @@ -32,7 +32,7 @@ use chrono::{DateTime, Utc}; use crate::openhuman::config::Config; use crate::openhuman::memory::tree::content_store::raw::raw_source_dir; -use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind, TreeStatus}; +use crate::openhuman::memory::tree::tree_source::types::Tree; /// Filename of the per-source registry mirror inside `raw//`. pub const SOURCE_FILE_NAME: &str = "_source.md"; diff --git a/src/openhuman/security/pairing.rs b/src/openhuman/security/pairing.rs index e86c041aa0..9dbf1e4eeb 100644 --- a/src/openhuman/security/pairing.rs +++ b/src/openhuman/security/pairing.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use std::time::Instant; #[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::os::unix::fs::OpenOptionsExt as _; /// Environment variable for the core JSON-RPC bearer token (see `crate::core::auth`). pub const CORE_TOKEN_ENV_VAR: &str = "OPENHUMAN_CORE_TOKEN"; diff --git a/src/openhuman/security/secrets.rs b/src/openhuman/security/secrets.rs index b624919631..1c8b7c3be9 100644 --- a/src/openhuman/security/secrets.rs +++ b/src/openhuman/security/secrets.rs @@ -214,6 +214,7 @@ impl SecretStore { }; let hex_key = read_result.with_context(|| { + #[allow(unused_mut)] let mut msg = format!( "Failed to read secret key file at {}", self.key_path.display() diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index c1a177e03c..b827539b11 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -534,7 +534,7 @@ impl HotkeyListenerKind { fn start_hotkey_listener( hotkey_str: &str, mode: hotkey::ActivationMode, - server_cancel: &CancellationToken, + _server_cancel: &CancellationToken, ) -> Result< ( HotkeyListenerKind, @@ -545,7 +545,7 @@ fn start_hotkey_listener( #[cfg(target_os = "macos")] { if hotkey_str.trim().eq_ignore_ascii_case("fn") { - return start_globe_hotkey_listener(mode, server_cancel); + return start_globe_hotkey_listener(mode, _server_cancel); } } diff --git a/src/openhuman/webview_accounts/ops.rs b/src/openhuman/webview_accounts/ops.rs index fa5171e084..ae761aedfa 100644 --- a/src/openhuman/webview_accounts/ops.rs +++ b/src/openhuman/webview_accounts/ops.rs @@ -32,7 +32,7 @@ struct Provider { /// Providers the welcome agent cares about. Keep this list aligned /// with the webview accounts system in `app/src-tauri/src/webview_accounts/`. -pub(crate) const PROVIDERS: &[Provider] = &[ +pub(self) const PROVIDERS: &[Provider] = &[ Provider { key: "gmail", host_suffix: ".google.com", diff --git a/src/openhuman/whatsapp_data/sqlite_retry.rs b/src/openhuman/whatsapp_data/sqlite_retry.rs index dbaf91a815..053c7358f1 100644 --- a/src/openhuman/whatsapp_data/sqlite_retry.rs +++ b/src/openhuman/whatsapp_data/sqlite_retry.rs @@ -7,7 +7,7 @@ use std::thread; use std::time::Duration; -use anyhow::{Context, Result}; +use anyhow::Result; /// Per-connection busy handler window (issue #2077). pub const BUSY_TIMEOUT: Duration = Duration::from_millis(5000); From 129d0a54437bc8bb5688f24b75a41c6d6e8e8630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Thu, 21 May 2026 11:14:06 +0900 Subject: [PATCH 04/11] refactor(tests): optimize runtime snapshot cache tests for concurrency - Updated the `runtime_snapshot_cache_hit_within_ttl` test to minimize lock hold time by reading cache fields under lock and asserting outside, reducing contention with other tests. - Simplified the `fetched_at` assignment to use a variable instead of calling `Instant::now()` directly. - Adjusted the `runtime_snapshot_cache_miss_after_ttl` test to directly assert on the `fetched_at` variable for clarity. - These changes enhance test reliability and maintainability, particularly in concurrent test scenarios. --- src/openhuman/app_state/ops_tests.rs | 32 ++++++++++--------- .../memory/tree/tree_source/source_file.rs | 2 +- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs index 0cdb42ef6c..b9be605f94 100644 --- a/src/openhuman/app_state/ops_tests.rs +++ b/src/openhuman/app_state/ops_tests.rs @@ -199,33 +199,35 @@ fn runtime_snapshot_cache_hit_within_ttl() { let _reset = SnapshotCacheResetGuard; let dummy = build_dummy_runtime_snapshot(); + let fetched_at = Instant::now(); *RUNTIME_SNAPSHOT_CACHE.lock() = Some(CachedRuntimeSnapshot { snapshot: dummy.clone(), - fetched_at: Instant::now(), + fetched_at, }); - let cache = RUNTIME_SNAPSHOT_CACHE.lock(); - let entry = cache.as_ref().expect("cache should have entry"); + // Read fields under lock, then assert outside to minimise lock hold time + // and avoid contention with other tests writing to the same global. + let (elapsed, phase) = { + let cache = RUNTIME_SNAPSHOT_CACHE.lock(); + let entry = cache.as_ref().expect("cache should have entry"); + (entry.fetched_at.elapsed(), entry.snapshot.autocomplete.phase.clone()) + }; assert!( - entry.fetched_at.elapsed() < RUNTIME_SNAPSHOT_TTL, + elapsed < RUNTIME_SNAPSHOT_TTL, "fresh entry should be within TTL" ); - assert_eq!(entry.snapshot.autocomplete.phase, dummy.autocomplete.phase); + assert_eq!(phase, dummy.autocomplete.phase); } +// This test verifies pure Instant arithmetic — no global cache state needed. +// Using the global cache here would race with cache_hit_within_ttl when tests +// run in parallel, causing a flaky "stale entry should be past TTL" failure. #[test] fn runtime_snapshot_cache_miss_after_ttl() { - let _reset = SnapshotCacheResetGuard; - - *RUNTIME_SNAPSHOT_CACHE.lock() = Some(CachedRuntimeSnapshot { - snapshot: build_dummy_runtime_snapshot(), - fetched_at: Instant::now() - (RUNTIME_SNAPSHOT_TTL + Duration::from_millis(100)), - }); - - let cache = RUNTIME_SNAPSHOT_CACHE.lock(); - let entry = cache.as_ref().expect("cache should have entry"); + let stale_age = RUNTIME_SNAPSHOT_TTL + Duration::from_millis(100); + let fetched_at = Instant::now() - stale_age; assert!( - entry.fetched_at.elapsed() >= RUNTIME_SNAPSHOT_TTL, + fetched_at.elapsed() >= RUNTIME_SNAPSHOT_TTL, "stale entry should be past TTL" ); } diff --git a/src/openhuman/memory/tree/tree_source/source_file.rs b/src/openhuman/memory/tree/tree_source/source_file.rs index 8694e96e28..72f91aee4c 100644 --- a/src/openhuman/memory/tree/tree_source/source_file.rs +++ b/src/openhuman/memory/tree/tree_source/source_file.rs @@ -32,7 +32,7 @@ use chrono::{DateTime, Utc}; use crate::openhuman::config::Config; use crate::openhuman::memory::tree::content_store::raw::raw_source_dir; -use crate::openhuman::memory::tree::tree_source::types::Tree; +use crate::openhuman::memory::tree::tree_source::types::{Tree, TreeKind, TreeStatus}; /// Filename of the per-source registry mirror inside `raw//`. pub const SOURCE_FILE_NAME: &str = "_source.md"; From 7bef7d56b2082d81f51ffe1ddf75af3168d239d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Thu, 21 May 2026 11:16:12 +0900 Subject: [PATCH 05/11] chore(dependencies): update Cargo.lock with new packages and versions - Added new dependencies: `encoding_rs` (v0.8.35), `motosan-ai-oauth` (v0.2.0), `system-configuration` (v0.7.0), and `system-configuration-sys` (v0.6.0) to the Cargo.lock file. - Updated existing dependencies to include `system-configuration` and `windows-registry`. - Minor formatting adjustments in `ops_tests.rs` for improved readability. --- app/src-tauri/Cargo.lock | 51 ++++++++++++++++++++++++++++ src/openhuman/app_state/ops_tests.rs | 5 ++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 54350c9523..79515e2817 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -2114,6 +2114,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -3384,9 +3393,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4309,6 +4320,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "motosan-ai-oauth" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16994a67367076b08479af83ca05503c4d423fc6631f849fb92fa787956ad557" +dependencies = [ + "base64 0.22.1", + "percent-encoding", + "rand 0.9.4", + "reqwest 0.12.28", + "serde", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -5064,6 +5091,7 @@ dependencies = [ "lettre", "log", "mail-parser", + "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", @@ -6363,6 +6391,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -6376,6 +6405,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "mime_guess", "native-tls", "percent-encoding", @@ -7714,6 +7744,27 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs index b9be605f94..00cee061fb 100644 --- a/src/openhuman/app_state/ops_tests.rs +++ b/src/openhuman/app_state/ops_tests.rs @@ -210,7 +210,10 @@ fn runtime_snapshot_cache_hit_within_ttl() { let (elapsed, phase) = { let cache = RUNTIME_SNAPSHOT_CACHE.lock(); let entry = cache.as_ref().expect("cache should have entry"); - (entry.fetched_at.elapsed(), entry.snapshot.autocomplete.phase.clone()) + ( + entry.fetched_at.elapsed(), + entry.snapshot.autocomplete.phase.clone(), + ) }; assert!( elapsed < RUNTIME_SNAPSHOT_TTL, From 8cf408e39ccac5ee9399b497dbf5065edcf1076c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Thu, 21 May 2026 11:40:39 +0900 Subject: [PATCH 06/11] refactor(tests): enhance concurrency handling in runtime snapshot cache tests - Updated the `runtime_snapshot_cache_hit_within_ttl` test to hold the lock during the entire write-then-read sequence, preventing concurrent tests from overwriting the cache. - This change improves test reliability and reduces contention issues in concurrent testing scenarios. --- src/openhuman/app_state/ops_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs index 00cee061fb..307d16a75c 100644 --- a/src/openhuman/app_state/ops_tests.rs +++ b/src/openhuman/app_state/ops_tests.rs @@ -200,15 +200,15 @@ fn runtime_snapshot_cache_hit_within_ttl() { let dummy = build_dummy_runtime_snapshot(); let fetched_at = Instant::now(); - *RUNTIME_SNAPSHOT_CACHE.lock() = Some(CachedRuntimeSnapshot { - snapshot: dummy.clone(), - fetched_at, - }); - // Read fields under lock, then assert outside to minimise lock hold time - // and avoid contention with other tests writing to the same global. + // Hold the lock for the entire write-then-read sequence so no concurrent + // test can overwrite RUNTIME_SNAPSHOT_CACHE between the write and the read. let (elapsed, phase) = { - let cache = RUNTIME_SNAPSHOT_CACHE.lock(); + let mut cache = RUNTIME_SNAPSHOT_CACHE.lock(); + *cache = Some(CachedRuntimeSnapshot { + snapshot: dummy.clone(), + fetched_at, + }); let entry = cache.as_ref().expect("cache should have entry"); ( entry.fetched_at.elapsed(), From 32f8da1118dcd828b0bcda7a7d54b35b6b7af0cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Fri, 22 May 2026 23:09:36 +0900 Subject: [PATCH 07/11] fix(mcp): use MCP_HTTP_ACCEPT in discover_authorization discover_authorization was sending Accept: application/json, causing strict MCP servers (e.g. GitBooks) to return 406 instead of 401. The function treated any non-401 as "no auth required", so OAuth discovery silently failed on those servers. The test discover_authorization_returns_none_when_not_401 was passing for the wrong reason (406 masking as non-401). With this fix the test server returns 200 and the test validates the intended path. Fixes the regression introduced in the accept-header change from this same branch. --- src/openhuman/mcp_client/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/mcp_client/client.rs b/src/openhuman/mcp_client/client.rs index 1bd2149489..cfd694cc60 100644 --- a/src/openhuman/mcp_client/client.rs +++ b/src/openhuman/mcp_client/client.rs @@ -288,7 +288,7 @@ impl McpHttpClient { .http .post(&self.endpoint) .header(CONTENT_TYPE, "application/json") - .header(ACCEPT, "application/json") + .header(ACCEPT, MCP_HTTP_ACCEPT) .body(serde_json::to_vec(&json!({ "jsonrpc": "2.0", "id": self.next_id.fetch_add(1, Ordering::Relaxed), From 898e14e94660dee98f17ef84f246fba849e6d47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Fri, 22 May 2026 23:17:32 +0900 Subject: [PATCH 08/11] fix: remove redundant pub(self) from PROVIDERS constant pub(self) is equivalent to no visibility modifier. Plain const is already private to the module, so the qualifier is misleading noise. --- src/openhuman/webview_accounts/ops.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/webview_accounts/ops.rs b/src/openhuman/webview_accounts/ops.rs index ae761aedfa..8add62b041 100644 --- a/src/openhuman/webview_accounts/ops.rs +++ b/src/openhuman/webview_accounts/ops.rs @@ -32,7 +32,7 @@ struct Provider { /// Providers the welcome agent cares about. Keep this list aligned /// with the webview accounts system in `app/src-tauri/src/webview_accounts/`. -pub(self) const PROVIDERS: &[Provider] = &[ +const PROVIDERS: &[Provider] = &[ Provider { key: "gmail", host_suffix: ".google.com", From a27ca76cb0de1da1f4fc721c77c681e13d8eb962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Fri, 22 May 2026 23:40:01 +0900 Subject: [PATCH 09/11] fix(i18n): remove duplicate German translation keys in de-3 and de-5 de-3.ts had subconscious.providerUnavailableTitle and providerSettings defined twice. de-5.ts had the entire settings.mcpServer and settings.developerMenu.mcpServer block duplicated at end of file. TypeScript TS1117 error blocked the E2E build. --- app/src/lib/i18n/chunks/de-3.ts | 2 -- app/src/lib/i18n/chunks/de-5.ts | 22 ---------------------- 2 files changed, 24 deletions(-) diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index b5857a82bd..996a81855a 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -123,8 +123,6 @@ const de3: TranslationMap = { 'subconscious.decision.failed': 'Fehlgeschlagen', 'subconscious.decision.cancelled': 'Abgesagt', 'subconscious.decision.skipped': 'Übersprungen', - 'subconscious.providerUnavailableTitle': 'Unterbewusstsein pausiert', - 'subconscious.providerSettings': 'KI-Einstellungen', 'actionable.complete': 'Komplett', 'actionable.dismiss': 'Entlassen', 'actionable.snooze': 'Schlummern', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 0e77924028..c9a3abf882 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -523,28 +523,6 @@ const de5: TranslationMap = { 'settings.mascot.colorYellow': 'Gelb', 'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar', 'settings.mascot.title': 'OpenHuman', - 'settings.developerMenu.mcpServer.title': 'MCP-Server', - 'settings.developerMenu.mcpServer.desc': - 'Externe MCP-Clients für die Verbindung zu OpenHuman konfigurieren', - 'settings.mcpServer.title': 'MCP-Server', - 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Tools', - 'settings.mcpServer.toolsSectionDesc': - 'Tools, die über den MCP-stdio-Server bereitgestellt werden, wenn openhuman-core mcp läuft', - 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', - 'settings.mcpServer.configSectionDesc': - 'Wähle deinen MCP-Client, um den passenden Konfigurations-Snippet zu erzeugen', - 'settings.mcpServer.copySnippet': 'In Zwischenablage kopieren', - 'settings.mcpServer.copied': 'Kopiert!', - 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', - 'settings.mcpServer.binaryPathNotFound': - 'OpenHuman-Binary nicht gefunden. Bei Quellbau bitte mit `cargo build --bin openhuman-core` bauen.', - 'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden', - 'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop', - 'settings.mcpServer.clientCursor': 'Cursor', - 'settings.mcpServer.clientCodex': 'Codex', - 'settings.mcpServer.clientZed': 'Zed', - 'settings.mcpServer.configFilePath': 'Konfigurationsdatei', - 'settings.mcpServer.clientSelectorAriaLabel': 'MCP-Client-Auswahl', }; export default de5; From 5ad6c9bb86214528de000f8f764d3a9dea0b439e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Sat, 23 May 2026 12:20:53 +0900 Subject: [PATCH 10/11] fix(tests): correct bad merge in runtime_snapshot_cache_miss_after_ttl Merge conflict resolution accidentally combined upstream's entry-based assert body with the branch's bare `fetched_at` variable reference. The variable is not declared in the upstream version; fix to use entry.fetched_at.elapsed() as upstream intended. --- src/openhuman/app_state/ops_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs index e847b387c3..be6d61a11e 100644 --- a/src/openhuman/app_state/ops_tests.rs +++ b/src/openhuman/app_state/ops_tests.rs @@ -245,7 +245,7 @@ fn runtime_snapshot_cache_miss_after_ttl() { let cache = RUNTIME_SNAPSHOT_CACHE.lock(); let entry = cache.as_ref().expect("cache should have entry"); assert!( - fetched_at.elapsed() >= RUNTIME_SNAPSHOT_TTL, + entry.fetched_at.elapsed() >= RUNTIME_SNAPSHOT_TTL, "stale entry should be past TTL" ); } From 1fa9588baac525417144c7a9eeb40f9a0504591a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=ED=98=95=EC=9A=B0?= Date: Sun, 24 May 2026 13:58:14 +0900 Subject: [PATCH 11/11] fix(pr): remove redundant MCP Accept headers and revert accidental openai_oauth commit --- Cargo.lock | 53 -- Cargo.toml | 1 - .../pages/onboarding/steps/ApiKeysStep.tsx | 155 +----- .../steps/__tests__/ApiKeysStep.test.tsx | 167 ------- src/openhuman/inference/mod.rs | 1 - .../inference/openai_oauth/config.rs | 11 - src/openhuman/inference/openai_oauth/flow.rs | 327 ------------ .../inference/openai_oauth/flow_tests.rs | 465 ------------------ src/openhuman/inference/openai_oauth/mod.rs | 14 - src/openhuman/inference/openai_oauth/store.rs | 133 ----- src/openhuman/inference/ops.rs | 73 --- src/openhuman/inference/ops_tests.rs | 107 +--- src/openhuman/inference/provider/factory.rs | 36 +- .../inference/provider/factory_test.rs | 19 - src/openhuman/inference/schemas.rs | 92 ---- src/openhuman/inference/schemas_tests.rs | 45 +- src/openhuman/mcp_client/client.rs | 6 +- 17 files changed, 19 insertions(+), 1686 deletions(-) delete mode 100644 app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx delete mode 100644 src/openhuman/inference/openai_oauth/config.rs delete mode 100644 src/openhuman/inference/openai_oauth/flow.rs delete mode 100644 src/openhuman/inference/openai_oauth/flow_tests.rs delete mode 100644 src/openhuman/inference/openai_oauth/mod.rs delete mode 100644 src/openhuman/inference/openai_oauth/store.rs diff --git a/Cargo.lock b/Cargo.lock index 562f507f88..50d510db30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3064,11 +3064,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -4354,22 +4352,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "motosan-ai-oauth" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16994a67367076b08479af83ca05503c4d423fc6631f849fb92fa787956ad557" -dependencies = [ - "base64 0.22.1", - "percent-encoding", - "rand 0.9.4", - "reqwest 0.12.28", - "serde", - "sha2 0.10.9", - "thiserror 2.0.18", - "tokio", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -5000,7 +4982,6 @@ dependencies = [ "log", "mail-parser", "matrix-sdk", - "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", @@ -6153,7 +6134,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -6167,7 +6147,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "mime_guess", "native-tls", "percent-encoding", @@ -7441,27 +7420,6 @@ dependencies = [ "windows 0.57.0", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys 0.8.7", - "libc", -] - [[package]] name = "tap" version = "1.0.1" @@ -9106,17 +9064,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 799c284deb..99d512a919 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,6 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f tracing-appender = "0.2" prometheus = { version = "0.14", default-features = false } urlencoding = "2.1" -motosan-ai-oauth = { version = "0.2", features = ["codex"] } thiserror = "2.0" ring = "0.17" prost = { version = "0.14", default-features = false } diff --git a/app/src/pages/onboarding/steps/ApiKeysStep.tsx b/app/src/pages/onboarding/steps/ApiKeysStep.tsx index eb305aaf51..5458e5ee3e 100644 --- a/app/src/pages/onboarding/steps/ApiKeysStep.tsx +++ b/app/src/pages/onboarding/steps/ApiKeysStep.tsx @@ -1,10 +1,7 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { setCloudProviderKey } from '../../../services/api/aiSettingsApi'; -import { callCoreRpc } from '../../../services/coreRpcClient'; -import { openUrl } from '../../../utils/openUrl'; -import { isTauri } from '../../../utils/tauriCommands/common'; import OnboardingNextButton from '../components/OnboardingNextButton'; interface ApiKeysStepProps { @@ -12,98 +9,17 @@ interface ApiKeysStepProps { onSkip: () => void; } -type OpenAiOAuthStatus = { connected: boolean; authMethod?: string | null }; - -const OPENAI_OAUTH_CONNECTED_LABEL = 'Connected with ChatGPT'; -const OPENAI_OAUTH_CONNECT_LABEL = 'Sign in with ChatGPT'; -const OPENAI_OAUTH_CALLBACK_HINT = - 'After signing in, paste the full redirect URL from your browser (starts with http://127.0.0.1:1455/).'; -const OPENAI_OAUTH_CALLBACK_PLACEHOLDER = 'http://127.0.0.1:1455/auth/callback?code=...&state=...'; - const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => { const { t } = useT(); const [openai, setOpenai] = useState(''); const [anthropic, setAnthropic] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [oauthConnected, setOauthConnected] = useState(false); - const [oauthBusy, setOauthBusy] = useState(false); - const [oauthAwaitingCallback, setOauthAwaitingCallback] = useState(false); - const [oauthCallbackUrl, setOauthCallbackUrl] = useState(''); - - const refreshOAuthStatus = useCallback(async () => { - if (!isTauri()) { - return; - } - try { - const res = await callCoreRpc<{ result: OpenAiOAuthStatus }>({ - method: 'openhuman.inference_openai_oauth_status', - params: {}, - }); - setOauthConnected(Boolean(res?.result?.connected)); - } catch (err) { - console.debug('[onboarding:api-keys] oauth status check failed', err); - } - }, []); - - useEffect(() => { - void refreshOAuthStatus(); - }, [refreshOAuthStatus]); - - const handleOpenAiOAuthStart = async () => { - if (!isTauri()) { - setError('ChatGPT sign-in is only available in the desktop app.'); - return; - } - setOauthBusy(true); - setError(null); - try { - const res = await callCoreRpc<{ result: { authUrl: string } }>({ - method: 'openhuman.inference_openai_oauth_start', - params: {}, - }); - const authUrl = res?.result?.authUrl?.trim(); - if (!authUrl) { - throw new Error('missing authUrl'); - } - setOauthAwaitingCallback(true); - await openUrl(authUrl); - } catch (err) { - console.warn('[onboarding:api-keys] oauth start failed', err); - setError('Could not start ChatGPT sign-in. Try again or use an API key.'); - } finally { - setOauthBusy(false); - } - }; - - const handleOpenAiOAuthComplete = async () => { - const callback = oauthCallbackUrl.trim(); - if (!callback) { - setError('Paste the redirect URL from your browser after signing in.'); - return; - } - setOauthBusy(true); - setError(null); - try { - await callCoreRpc({ - method: 'openhuman.inference_openai_oauth_complete', - params: { callback_url: callback }, - }); - setOauthCallbackUrl(''); - setOauthAwaitingCallback(false); - setOauthConnected(true); - } catch (err) { - console.warn('[onboarding:api-keys] oauth complete failed', err); - setError('ChatGPT sign-in did not complete. Check the redirect URL and try again.'); - } finally { - setOauthBusy(false); - } - }; const handleSave = async () => { const trimmedOpenai = openai.trim(); const trimmedAnthropic = anthropic.trim(); - if (!trimmedOpenai && !trimmedAnthropic && !oauthConnected) { + if (!trimmedOpenai && !trimmedAnthropic) { onSkip(); return; } @@ -140,65 +56,12 @@ const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => {
-
-
- - {t('onboarding.apiKeys.openaiLabel')} - - {oauthConnected ? ( - - {OPENAI_OAUTH_CONNECTED_LABEL} - - ) : null} -
-

- Use ChatGPT Plus/Pro (subscription) or an OpenAI API key — not both required. -

- - {oauthAwaitingCallback && !oauthConnected ? ( -
-

- {OPENAI_OAUTH_CALLBACK_HINT} -

- { - setOauthCallbackUrl(e.target.value); - setError(null); - }} - className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-xs text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" - /> - -
- ) : null} -
-
- - or API key - -
-
+
+ { type="button" onClick={onSkip} disabled={saving} - className="text-xs text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 underline disabled:opacity-50"> + className="text-xs text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 underline disabled:opacity-50"> {t('onboarding.apiKeys.skipForNow')}
diff --git a/app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx deleted file mode 100644 index 31fb03dc33..0000000000 --- a/app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { setCloudProviderKey } from '../../../../services/api/aiSettingsApi'; -import { callCoreRpc } from '../../../../services/coreRpcClient'; -import { renderWithProviders } from '../../../../test/test-utils'; -import { openUrl } from '../../../../utils/openUrl'; -import { isTauri } from '../../../../utils/tauriCommands/common'; -import ApiKeysStep from '../ApiKeysStep'; - -vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); - -vi.mock('../../../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) })); - -vi.mock('../../../../utils/tauriCommands/common', () => ({ isTauri: vi.fn(() => true) })); - -vi.mock('../../../../services/api/aiSettingsApi', () => ({ - setCloudProviderKey: vi.fn().mockResolvedValue(undefined), -})); - -describe('ApiKeysStep OpenAI OAuth', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(isTauri).mockReturnValue(true); - vi.mocked(openUrl).mockResolvedValue(undefined); - vi.mocked(setCloudProviderKey).mockResolvedValue(undefined); - }); - - it('shows connected badge when oauth status reports connected', async () => { - vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } }); - - renderWithProviders(); - - expect(await screen.findByTestId('onboarding-openai-oauth-connected')).toBeInTheDocument(); - expect(screen.getByText('Connected with ChatGPT')).toBeInTheDocument(); - }); - - it('starts oauth and accepts pasted callback URL', async () => { - vi.mocked(callCoreRpc) - .mockResolvedValueOnce({ result: { connected: false } }) - .mockResolvedValueOnce({ - result: { - authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test', - state: 'state-1', - redirectUri: 'http://127.0.0.1:1455/auth/callback', - }, - }) - .mockResolvedValueOnce({ result: { connected: true } }); - - renderWithProviders(); - - fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); - - await waitFor(() => { - expect(openUrl).toHaveBeenCalledWith( - 'https://auth.openai.com/oauth/authorize?client_id=test' - ); - }); - - const input = await screen.findByTestId('onboarding-openai-oauth-callback-input'); - fireEvent.change(input, { - target: { value: 'http://127.0.0.1:1455/auth/callback?code=abc&state=state-1' }, - }); - fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete')); - - await waitFor(() => { - expect(callCoreRpc).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'openhuman.inference_openai_oauth_complete', - params: { callback_url: 'http://127.0.0.1:1455/auth/callback?code=abc&state=state-1' }, - }) - ); - }); - - expect(await screen.findByTestId('onboarding-openai-oauth-connected')).toBeInTheDocument(); - }); - - it('shows a desktop-only error without calling core outside Tauri', async () => { - vi.mocked(isTauri).mockReturnValue(false); - - renderWithProviders(); - - fireEvent.click(screen.getByTestId('onboarding-openai-oauth-connect')); - - expect( - await screen.findByText('ChatGPT sign-in is only available in the desktop app.') - ).toBeInTheDocument(); - expect(callCoreRpc).not.toHaveBeenCalled(); - expect(openUrl).not.toHaveBeenCalled(); - }); - - it('reports an oauth start failure when core omits authUrl', async () => { - vi.mocked(callCoreRpc) - .mockResolvedValueOnce({ result: { connected: false } }) - .mockResolvedValueOnce({ result: { authUrl: ' ' } }); - - renderWithProviders(); - - fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); - - expect( - await screen.findByText('Could not start ChatGPT sign-in. Try again or use an API key.') - ).toBeInTheDocument(); - expect(openUrl).not.toHaveBeenCalled(); - }); - - it('requires a pasted callback before completing oauth', async () => { - vi.mocked(callCoreRpc) - .mockResolvedValueOnce({ result: { connected: false } }) - .mockResolvedValueOnce({ - result: { authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test' }, - }); - - renderWithProviders(); - - fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); - await screen.findByTestId('onboarding-openai-oauth-callback-input'); - fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete')); - - expect( - await screen.findByText('Paste the redirect URL from your browser after signing in.') - ).toBeInTheDocument(); - expect(callCoreRpc).not.toHaveBeenCalledWith( - expect.objectContaining({ method: 'openhuman.inference_openai_oauth_complete' }) - ); - }); - - it('reports an oauth completion failure and keeps the callback form visible', async () => { - vi.mocked(callCoreRpc) - .mockResolvedValueOnce({ result: { connected: false } }) - .mockResolvedValueOnce({ - result: { authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test' }, - }) - .mockRejectedValueOnce(new Error('state mismatch')); - - renderWithProviders(); - - fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); - const input = await screen.findByTestId('onboarding-openai-oauth-callback-input'); - fireEvent.change(input, { - target: { value: 'http://127.0.0.1:1455/auth/callback?code=abc&state=wrong' }, - }); - fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete')); - - expect( - await screen.findByText( - 'ChatGPT sign-in did not complete. Check the redirect URL and try again.' - ) - ).toBeInTheDocument(); - expect(screen.getByTestId('onboarding-openai-oauth-callback-input')).toBeInTheDocument(); - }); - - it('continues without saving API keys when oauth is already connected', async () => { - const onNext = vi.fn(); - vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } }); - - renderWithProviders(); - - await screen.findByTestId('onboarding-openai-oauth-connected'); - fireEvent.click(screen.getByTestId('onboarding-next-button')); - - await waitFor(() => { - expect(onNext).toHaveBeenCalledTimes(1); - }); - expect(setCloudProviderKey).not.toHaveBeenCalled(); - }); -}); diff --git a/src/openhuman/inference/mod.rs b/src/openhuman/inference/mod.rs index 7a04d75ecd..3848ed5873 100644 --- a/src/openhuman/inference/mod.rs +++ b/src/openhuman/inference/mod.rs @@ -17,7 +17,6 @@ pub mod http; pub mod local; pub mod model_context; pub mod model_ids; -pub mod openai_oauth; pub mod ops; pub mod parse; pub mod paths; diff --git a/src/openhuman/inference/openai_oauth/config.rs b/src/openhuman/inference/openai_oauth/config.rs deleted file mode 100644 index 21f6d8aaf1..0000000000 --- a/src/openhuman/inference/openai_oauth/config.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! OpenAI Codex (ChatGPT subscription) OAuth endpoints and client registration. - -use motosan_ai_oauth::providers::codex::codex; -use motosan_ai_oauth::OAuthConfig; - -/// Loopback redirect registered with the Codex public OAuth app. -pub const REDIRECT_URI: &str = "http://127.0.0.1:1455/auth/callback"; - -pub fn codex_oauth_config() -> OAuthConfig { - codex() -} diff --git a/src/openhuman/inference/openai_oauth/flow.rs b/src/openhuman/inference/openai_oauth/flow.rs deleted file mode 100644 index a412bf7fe1..0000000000 --- a/src/openhuman/inference/openai_oauth/flow.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! OAuth start / complete / status for OpenAI Codex (ChatGPT subscription). - -use std::path::PathBuf; -use std::time::Duration; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use chrono::{DateTime, Utc}; -use motosan_ai_oauth::StateStrategy; -use rand::RngExt as _; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use crate::openhuman::config::Config; -use crate::openhuman::credentials::state_dir_from_config; - -use super::config::{codex_oauth_config, REDIRECT_URI}; -use super::store::{persist_openai_oauth_token, OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY}; - -const LOG_PREFIX: &str = "[inference][openai-oauth]"; -const PENDING_FILENAME: &str = "openai-oauth-pending.json"; -const PENDING_TTL_SECS: u64 = 600; -const OAUTH_HTTP_TIMEOUT_SECS: u64 = 20; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PendingOAuth { - state: String, - verifier: String, - redirect_uri: String, - created_at: u64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OpenAiOAuthStartResult { - pub auth_url: String, - pub state: String, - pub redirect_uri: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OpenAiOAuthStatusResult { - pub connected: bool, - pub profile_id: Option, - pub expires_at: Option>, - pub auth_method: Option, -} - -fn pending_path(config: &Config) -> PathBuf { - state_dir_from_config(config).join(PENDING_FILENAME) -} - -fn generate_pkce() -> (String, String) { - let mut bytes = [0u8; 64]; - rand::rng().fill(&mut bytes); - let verifier = URL_SAFE_NO_PAD.encode(bytes); - let hash = Sha256::digest(verifier.as_bytes()); - let challenge = URL_SAFE_NO_PAD.encode(hash); - (verifier, challenge) -} - -fn random_state() -> String { - let mut state_bytes = [0u8; 16]; - rand::rng().fill(&mut state_bytes); - URL_SAFE_NO_PAD.encode(state_bytes) -} - -fn write_pending(config: &Config, pending: &PendingOAuth) -> Result<(), String> { - let path = pending_path(config); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let json = serde_json::to_vec_pretty(pending).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string())?; - log::debug!("{LOG_PREFIX} pending session written"); - Ok(()) -} - -fn read_pending(config: &Config) -> Result, String> { - let path = pending_path(config); - if !path.exists() { - return Ok(None); - } - let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; - if bytes.is_empty() { - return Ok(None); - } - let pending: PendingOAuth = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; - let now = unix_now_secs(); - if now.saturating_sub(pending.created_at) > PENDING_TTL_SECS { - let _ = std::fs::remove_file(&path); - return Ok(None); - } - Ok(Some(pending)) -} - -fn clear_pending(config: &Config) { - let path = pending_path(config); - if path.exists() { - let _ = std::fs::remove_file(path); - } -} - -pub fn start_openai_oauth(config: &Config) -> Result { - let oauth_cfg = codex_oauth_config(); - let (verifier, challenge) = generate_pkce(); - let state = match oauth_cfg.state_strategy { - StateStrategy::Random => random_state(), - StateStrategy::EqualsVerifier => verifier.clone(), - }; - - let pending = PendingOAuth { - state: state.clone(), - verifier, - redirect_uri: REDIRECT_URI.to_string(), - created_at: unix_now_secs(), - }; - write_pending(config, &pending)?; - - let auth_url = build_authorize_url(&oauth_cfg, &challenge, &state, REDIRECT_URI); - log::info!("{LOG_PREFIX} oauth start state_len={}", state.len()); - - Ok(OpenAiOAuthStartResult { - auth_url, - state, - redirect_uri: REDIRECT_URI.to_string(), - }) -} - -pub fn parse_callback_input(input: &str) -> Result<(String, String), String> { - let trimmed = input.trim(); - if trimmed.is_empty() { - return Err("callback URL is required".to_string()); - } - - let query = if let Ok(parsed) = url::Url::parse(trimmed) { - parsed.query().unwrap_or("").to_string() - } else if trimmed.contains('=') { - trimmed.to_string() - } else { - return Err("invalid callback URL".to_string()); - }; - - let mut code: Option = None; - let mut state: Option = None; - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - match key.as_ref() { - "code" if !value.is_empty() => code = Some(value.into_owned()), - "state" if !value.is_empty() => state = Some(value.into_owned()), - _ => {} - } - } - - let code = code.ok_or_else(|| "callback URL missing code parameter".to_string())?; - let state = state.ok_or_else(|| "callback URL missing state parameter".to_string())?; - Ok((code, state)) -} - -pub async fn complete_openai_oauth( - config: &Config, - callback_input: &str, -) -> Result { - let pending = read_pending(config)? - .ok_or_else(|| "no pending OAuth session; call openai_oauth_start first".to_string())?; - - let (code, returned_state) = parse_callback_input(callback_input)?; - if returned_state != pending.state { - clear_pending(config); - return Err("OAuth state mismatch — try connecting again".to_string()); - } - - let oauth_cfg = codex_oauth_config(); - let token = - exchange_authorization_code(&oauth_cfg, &code, &pending.verifier, &pending.redirect_uri) - .await?; - - clear_pending(config); - let profile = persist_openai_oauth_token(config, &token)?; - log::info!("{LOG_PREFIX} oauth complete profile_id={}", profile.id); - - Ok(serde_json::json!({ - "connected": true, - "profileId": profile.id, - "provider": OPENAI_PROVIDER_KEY, - "authMethod": "oauth", - })) -} - -pub fn openai_oauth_status(config: &Config) -> Result { - use crate::openhuman::credentials::profiles::AuthProfileKind; - use crate::openhuman::credentials::AuthService; - - let auth = AuthService::from_config(config); - let profile = auth - .get_profile(OPENAI_PROVIDER_KEY, Some(OPENAI_OAUTH_PROFILE_NAME)) - .map_err(|e| e.to_string())?; - - let Some(profile) = profile else { - return Ok(OpenAiOAuthStatusResult { - connected: false, - profile_id: None, - expires_at: None, - auth_method: None, - }); - }; - - if profile.kind != AuthProfileKind::OAuth { - return Ok(OpenAiOAuthStatusResult { - connected: false, - profile_id: Some(profile.id), - expires_at: None, - auth_method: Some("token".to_string()), - }); - } - - Ok(OpenAiOAuthStatusResult { - connected: true, - profile_id: Some(profile.id), - expires_at: profile.token_set.as_ref().and_then(|t| t.expires_at), - auth_method: Some("oauth".to_string()), - }) -} - -pub fn disconnect_openai_oauth(config: &Config) -> Result { - use crate::openhuman::credentials::AuthService; - - let auth = AuthService::from_config(config); - let removed = auth - .remove_profile(OPENAI_PROVIDER_KEY, OPENAI_OAUTH_PROFILE_NAME) - .map_err(|e| e.to_string())?; - clear_pending(config); - Ok(serde_json::json!({ "disconnected": removed })) -} - -pub(super) fn build_authorize_url( - config: &motosan_ai_oauth::OAuthConfig, - challenge: &str, - state: &str, - redirect_uri: &str, -) -> String { - let mut url = reqwest::Url::parse(config.auth_url).expect("auth_url must be valid"); - { - let mut q = url.query_pairs_mut(); - q.append_pair("client_id", config.client_id) - .append_pair("response_type", "code") - .append_pair("redirect_uri", redirect_uri) - .append_pair("scope", &config.scopes.join(" ")) - .append_pair("state", state) - .append_pair("code_challenge", challenge) - .append_pair("code_challenge_method", "S256"); - for (k, v) in config.extra_auth_params { - q.append_pair(k, v); - } - } - url.to_string() -} - -pub(super) async fn exchange_authorization_code( - config: &motosan_ai_oauth::OAuthConfig, - code: &str, - verifier: &str, - redirect_uri: &str, -) -> Result { - // Per RFC 6749 §4.1.3 the token request only requires grant_type, code, - // redirect_uri, code_verifier (PKCE), and client_id. `state` belongs to the - // authorization request / callback validation, not this exchange. - let mut params = vec![ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", redirect_uri), - ("code_verifier", verifier), - ("client_id", config.client_id), - ]; - if let Some(secret) = config.client_secret { - params.push(("client_secret", secret)); - } - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(OAUTH_HTTP_TIMEOUT_SECS)) - .build() - .map_err(|e| e.to_string())?; - - let resp = client - .post(config.token_url) - .header("Accept", "application/json") - .form(¶ms) - .send() - .await - .map_err(|e| { - log::warn!("{LOG_PREFIX} token exchange request failed: {e}"); - e.to_string() - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - log::warn!( - "{LOG_PREFIX} token exchange http_status={status} body_len={}", - body.len() - ); - return Err(format!("HTTP {status}: {body}")); - } - - #[derive(serde::Deserialize)] - struct RawTokenResponse { - access_token: String, - #[serde(default)] - refresh_token: Option, - #[serde(default)] - id_token: Option, - expires_in: u64, - } - - let raw: RawTokenResponse = resp.json().await.map_err(|e| e.to_string())?; - Ok(motosan_ai_oauth::Token { - access_token: raw.access_token, - refresh_token: raw.refresh_token.unwrap_or_default(), - id_token: raw.id_token, - expires_in: raw.expires_in, - issued_at: unix_now_secs(), - }) -} - -fn unix_now_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} diff --git a/src/openhuman/inference/openai_oauth/flow_tests.rs b/src/openhuman/inference/openai_oauth/flow_tests.rs deleted file mode 100644 index 8a6e697a98..0000000000 --- a/src/openhuman/inference/openai_oauth/flow_tests.rs +++ /dev/null @@ -1,465 +0,0 @@ -use super::flow::{build_authorize_url, exchange_authorization_code, parse_callback_input}; -use super::store::persist_openai_oauth_token; -use super::{ - complete_openai_oauth, disconnect_openai_oauth, openai_oauth_status, start_openai_oauth, -}; -use crate::openhuman::config::Config; -use crate::openhuman::credentials::profiles::{ - AuthProfile, AuthProfileKind, AuthProfilesStore, TokenSet, -}; -use crate::openhuman::inference::openai_oauth::lookup_openai_bearer_token; -use crate::openhuman::inference::openai_oauth::store::{ - OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY, -}; -use crate::openhuman::inference::provider::factory::lookup_key_for_slug; -use chrono::{Duration, Utc}; -use motosan_ai_oauth::{OAuthConfig, StateStrategy, TokenBodyFormat}; -use tempfile::tempdir; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -fn test_config(tmp: &tempfile::TempDir) -> Config { - Config { - config_path: tmp.path().join("config.toml"), - ..Config::default() - } -} - -fn runtime() -> tokio::runtime::Runtime { - tokio::runtime::Runtime::new().unwrap() -} - -fn unsigned_jwt(payload: serde_json::Value) -> String { - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - - let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#); - let payload = URL_SAFE_NO_PAD.encode(payload.to_string()); - format!("{header}.{payload}.") -} - -fn test_oauth_config(token_url: &'static str) -> OAuthConfig { - OAuthConfig { - client_id: "client-id", - client_secret: Some("client-secret"), - auth_url: "https://auth.example.test/oauth/authorize", - token_url, - scopes: &["scope-a", "scope-b"], - redirect_port: Some(1455), - callback_path: "/auth/callback", - redirect_uri_host: "127.0.0.1", - token_body: TokenBodyFormat::Form, - extra_auth_params: &[("prompt", "consent")], - state_strategy: StateStrategy::Random, - } -} - -#[test] -fn start_openai_oauth_returns_authorize_url() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - - let start = start_openai_oauth(&config).unwrap(); - assert!(start.auth_url.contains("auth.openai.com")); - assert!(start.auth_url.contains("code_challenge=")); - assert_eq!(start.redirect_uri, "http://127.0.0.1:1455/auth/callback"); - assert!(!start.state.is_empty()); - assert!(!openai_oauth_status(&config).unwrap().connected); -} - -#[test] -fn build_authorize_url_includes_codex_pkce_and_extra_params() { - let url = build_authorize_url( - &test_oauth_config("https://token.example.test/oauth/token"), - "challenge-123", - "state-123", - "http://127.0.0.1:1455/auth/callback", - ); - let parsed = reqwest::Url::parse(&url).unwrap(); - let pairs = parsed - .query_pairs() - .into_owned() - .collect::>(); - - assert_eq!( - pairs.get("client_id").map(String::as_str), - Some("client-id") - ); - assert_eq!(pairs.get("response_type").map(String::as_str), Some("code")); - assert_eq!( - pairs.get("scope").map(String::as_str), - Some("scope-a scope-b") - ); - assert_eq!(pairs.get("state").map(String::as_str), Some("state-123")); - assert_eq!( - pairs.get("code_challenge").map(String::as_str), - Some("challenge-123") - ); - assert_eq!( - pairs.get("code_challenge_method").map(String::as_str), - Some("S256") - ); - assert_eq!(pairs.get("prompt").map(String::as_str), Some("consent")); -} - -#[test] -fn parse_callback_input_accepts_full_redirect_url() { - let url = "http://127.0.0.1:1455/auth/callback?code=abc&state=xyz"; - let (code, state) = parse_callback_input(url).unwrap(); - assert_eq!(code, "abc"); - assert_eq!(state, "xyz"); -} - -#[test] -fn parse_callback_input_accepts_raw_query_string() { - let (code, state) = parse_callback_input("code=abc%20123&state=xyz").unwrap(); - assert_eq!(code, "abc 123"); - assert_eq!(state, "xyz"); -} - -#[test] -fn parse_callback_input_rejects_missing_code() { - let err = parse_callback_input("http://127.0.0.1:1455/auth/callback?state=xyz").unwrap_err(); - assert!(err.contains("code")); -} - -#[test] -fn parse_callback_input_rejects_blank_invalid_and_missing_state() { - let blank = parse_callback_input(" ").unwrap_err(); - assert!(blank.contains("required")); - - let invalid = parse_callback_input("not-a-callback").unwrap_err(); - assert!(invalid.contains("invalid")); - - let missing_state = - parse_callback_input("http://127.0.0.1:1455/auth/callback?code=abc").unwrap_err(); - assert!(missing_state.contains("state")); -} - -#[test] -fn complete_openai_oauth_rejects_missing_pending_session() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let err = runtime() - .block_on(complete_openai_oauth( - &config, - "http://127.0.0.1:1455/auth/callback?code=fake&state=state", - )) - .unwrap_err(); - assert!(err.contains("no pending OAuth session")); -} - -#[test] -fn complete_openai_oauth_rejects_expired_pending_session() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - std::fs::write( - tmp.path().join("openai-oauth-pending.json"), - serde_json::json!({ - "state": "state", - "verifier": "verifier", - "redirect_uri": "http://127.0.0.1:1455/auth/callback", - "created_at": 1_u64, - }) - .to_string(), - ) - .unwrap(); - - let err = runtime() - .block_on(complete_openai_oauth( - &config, - "http://127.0.0.1:1455/auth/callback?code=fake&state=state", - )) - .unwrap_err(); - assert!(err.contains("no pending OAuth session")); - assert!(!tmp.path().join("openai-oauth-pending.json").exists()); -} - -#[test] -fn complete_openai_oauth_rejects_state_mismatch() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let start = start_openai_oauth(&config).unwrap(); - let callback = format!( - "http://127.0.0.1:1455/auth/callback?code=fake&state=not-{}", - start.state - ); - let err = runtime() - .block_on(complete_openai_oauth(&config, &callback)) - .unwrap_err(); - assert!(err.contains("state mismatch")); -} - -#[tokio::test] -async fn exchange_authorization_code_parses_successful_token_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "access-token", - "refresh_token": "refresh-token", - "id_token": "id-token", - "expires_in": 3600, - }))) - .mount(&server) - .await; - let token_url: &'static str = Box::leak(format!("{}/token", server.uri()).into_boxed_str()); - - let token = exchange_authorization_code( - &test_oauth_config(token_url), - "code-123", - "verifier-123", - "http://127.0.0.1:1455/auth/callback", - ) - .await - .unwrap(); - - assert_eq!(token.access_token, "access-token"); - assert_eq!(token.refresh_token, "refresh-token"); - assert_eq!(token.id_token.as_deref(), Some("id-token")); - assert_eq!(token.expires_in, 3600); - assert!(token.issued_at > 0); -} - -#[tokio::test] -async fn exchange_authorization_code_reports_http_errors() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(400).set_body_string("bad auth code")) - .mount(&server) - .await; - let token_url: &'static str = Box::leak(format!("{}/token", server.uri()).into_boxed_str()); - - let err = exchange_authorization_code( - &test_oauth_config(token_url), - "code-123", - "verifier-123", - "http://127.0.0.1:1455/auth/callback", - ) - .await - .unwrap_err(); - - assert!(err.contains("HTTP 400")); - assert!(err.contains("bad auth code")); -} - -#[test] -fn persist_openai_oauth_token_stores_oauth_profile_with_metadata() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let access_token = unsigned_jwt(serde_json::json!({ "sub": "acct_123" })); - let token = motosan_ai_oauth::Token { - access_token: access_token.clone(), - refresh_token: "refresh-token".into(), - id_token: Some("id-token".into()), - expires_in: 3600, - issued_at: 123, - }; - - let profile = persist_openai_oauth_token(&config, &token).unwrap(); - assert_eq!(profile.kind, AuthProfileKind::OAuth); - assert_eq!( - profile.metadata.get("account_id").map(String::as_str), - Some("acct_123") - ); - assert_eq!( - profile - .token_set - .as_ref() - .map(|set| set.access_token.as_str()), - Some(access_token.as_str()) - ); - assert_eq!( - profile - .token_set - .as_ref() - .and_then(|set| set.refresh_token.as_deref()), - Some("refresh-token") - ); - assert!(profile - .token_set - .as_ref() - .and_then(|set| set.expires_at) - .is_some()); - - let data = AuthProfilesStore::new(tmp.path(), false).load().unwrap(); - let stored = data.profiles.get(&profile.id).unwrap(); - assert_eq!(stored.id, profile.id); -} - -#[test] -fn openai_oauth_status_reports_token_profile_as_disconnected() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let store = AuthProfilesStore::new(tmp.path(), false); - store - .upsert_profile( - AuthProfile::new_token( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - "sk-token-profile".to_string(), - ), - true, - ) - .unwrap(); - - let status = openai_oauth_status(&config).unwrap(); - assert!(!status.connected); - assert_eq!(status.auth_method.as_deref(), Some("token")); - assert!(status.profile_id.is_some()); -} - -#[test] -fn lookup_key_for_slug_prefers_api_key_over_oauth_for_openai() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let store = AuthProfilesStore::new(tmp.path(), false); - - let oauth_profile = AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: "oauth-access".into(), - refresh_token: Some("refresh".into()), - id_token: None, - expires_at: Some(Utc::now() + Duration::hours(1)), - token_type: Some("Bearer".into()), - scope: None, - }, - ); - store.upsert_profile(oauth_profile, true).unwrap(); - - let api_profile = - AuthProfile::new_token("provider:openai", "default", "sk-api-key".to_string()); - store.upsert_profile(api_profile, true).unwrap(); - - // The standard `lookup_key_for_slug` path resolves the API key first; the - // OAuth fallback only fires when no API key is present. - let token = lookup_key_for_slug("openai", &config).unwrap(); - assert_eq!(token, "sk-api-key"); -} - -#[test] -fn lookup_openai_bearer_token_uses_oauth_when_api_key_missing() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let store = AuthProfilesStore::new(tmp.path(), false); - let oauth_profile = AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: "oauth-access".into(), - refresh_token: Some("refresh".into()), - id_token: None, - expires_at: Some(Utc::now() + Duration::hours(1)), - token_type: Some("Bearer".into()), - scope: None, - }, - ); - store.upsert_profile(oauth_profile, true).unwrap(); - - let token = lookup_openai_bearer_token(&config).unwrap(); - assert_eq!(token.as_deref(), Some("oauth-access")); -} - -#[test] -fn lookup_key_for_slug_uses_legacy_openai_api_key_when_new_style_is_empty() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let store = AuthProfilesStore::new(tmp.path(), false); - let oauth_profile = AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: " ".into(), - refresh_token: None, - id_token: None, - expires_at: Some(Utc::now() + Duration::hours(1)), - token_type: Some("Bearer".into()), - scope: None, - }, - ); - store.upsert_profile(oauth_profile, true).unwrap(); - store - .upsert_profile( - AuthProfile::new_token("openai", "default", "sk-legacy-key".to_string()), - true, - ) - .unwrap(); - - // Legacy bare-slug key resolves through the standard path's legacy - // fallback, ahead of the OAuth fallback. - let token = lookup_key_for_slug("openai", &config).unwrap(); - assert_eq!(token, "sk-legacy-key"); -} - -#[test] -fn lookup_openai_bearer_token_keeps_expired_token_when_refresh_fails_without_runtime() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let store = AuthProfilesStore::new(tmp.path(), false); - let oauth_profile = AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: "expired-access".into(), - refresh_token: Some("refresh".into()), - id_token: None, - expires_at: Some(Utc::now() - Duration::minutes(5)), - token_type: Some("Bearer".into()), - scope: None, - }, - ); - store.upsert_profile(oauth_profile, true).unwrap(); - - let token = lookup_openai_bearer_token(&config).unwrap(); - assert_eq!(token.as_deref(), Some("expired-access")); -} - -#[test] -fn lookup_openai_bearer_token_returns_none_without_profiles_or_access_token() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - assert_eq!(lookup_openai_bearer_token(&config).unwrap(), None); - - let store = AuthProfilesStore::new(tmp.path(), false); - let empty_oauth_profile = AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: " ".into(), - refresh_token: None, - id_token: None, - expires_at: Some(Utc::now() - Duration::hours(1)), - token_type: Some("Bearer".into()), - scope: None, - }, - ); - store.upsert_profile(empty_oauth_profile, true).unwrap(); - - assert_eq!(lookup_openai_bearer_token(&config).unwrap(), None); -} - -#[test] -fn disconnect_openai_oauth_clears_profile() { - let tmp = tempdir().unwrap(); - let config = test_config(&tmp); - let store = AuthProfilesStore::new(tmp.path(), false); - let profile = AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: "oauth-access".into(), - refresh_token: None, - id_token: None, - expires_at: None, - token_type: Some("Bearer".into()), - scope: None, - }, - ); - store.upsert_profile(profile, true).unwrap(); - assert!(openai_oauth_status(&config).unwrap().connected); - - disconnect_openai_oauth(&config).unwrap(); - assert!(!openai_oauth_status(&config).unwrap().connected); -} diff --git a/src/openhuman/inference/openai_oauth/mod.rs b/src/openhuman/inference/openai_oauth/mod.rs deleted file mode 100644 index 21b87da1e7..0000000000 --- a/src/openhuman/inference/openai_oauth/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! ChatGPT / OpenAI Codex subscription OAuth for the `openai` cloud provider slug. - -mod config; -mod flow; -mod store; - -#[cfg(test)] -#[path = "flow_tests.rs"] -mod tests; - -pub use flow::{ - complete_openai_oauth, disconnect_openai_oauth, openai_oauth_status, start_openai_oauth, -}; -pub use store::{lookup_openai_bearer_token, OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY}; diff --git a/src/openhuman/inference/openai_oauth/store.rs b/src/openhuman/inference/openai_oauth/store.rs deleted file mode 100644 index f80eef6eb9..0000000000 --- a/src/openhuman/inference/openai_oauth/store.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Persist and resolve OpenAI OAuth tokens for the `openai` cloud provider slug. - -use base64::Engine; -use chrono::{Duration, Utc}; -use motosan_ai_oauth::Token; - -use crate::openhuman::config::Config; -use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet}; -use crate::openhuman::credentials::{state_dir_from_config, AuthService}; - -use super::config::codex_oauth_config; - -const LOG_PREFIX: &str = "[inference][openai-oauth][store]"; - -pub const OPENAI_PROVIDER_KEY: &str = "provider:openai"; -pub const OPENAI_OAUTH_PROFILE_NAME: &str = "oauth"; - -fn token_set_from_codex(token: &Token) -> TokenSet { - let expires_at = - (token.expires_in > 0).then(|| Utc::now() + Duration::seconds(token.expires_in as i64)); - TokenSet { - access_token: token.access_token.clone(), - refresh_token: (!token.refresh_token.is_empty()).then(|| token.refresh_token.clone()), - id_token: token.id_token.clone(), - expires_at, - token_type: Some("Bearer".to_string()), - scope: None, - } -} - -pub fn persist_openai_oauth_token(config: &Config, token: &Token) -> Result { - let mut profile = AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - token_set_from_codex(token), - ); - if let Some(account_id) = extract_account_id_from_access_token(&token.access_token) { - profile - .metadata - .insert("account_id".to_string(), account_id); - } - - let store = auth_profiles_store(config); - store - .upsert_profile(profile.clone(), true) - .map_err(|e| e.to_string())?; - Ok(profile) -} - -fn auth_profiles_store(config: &Config) -> AuthProfilesStore { - AuthProfilesStore::new(&state_dir_from_config(config), config.secrets.encrypt) -} - -fn try_refresh_oauth_token(refresh: &str) -> Result { - let cfg = codex_oauth_config(); - let refresh = refresh.to_string(); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - // `block_in_place` lets the multi-thread runtime move other tasks off this - // worker before we synchronously drive the refresh future, avoiding a - // deadlock when this lookup is reached from inside an async caller. - return tokio::task::block_in_place(|| { - handle.block_on(motosan_ai_oauth::refresh(&cfg, &refresh)) - }) - .map_err(|e| e.to_string()); - } - Err("tokio runtime required to refresh openai oauth token".to_string()) -} - -fn extract_account_id_from_access_token(access_token: &str) -> Option { - let payload = access_token.split('.').nth(1)?; - let padded = match payload.len() % 4 { - 0 => payload.to_string(), - n => format!("{}{}", payload, "=".repeat(4 - n)), - }; - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(padded.as_bytes()) - .or_else(|_| base64::engine::general_purpose::STANDARD.decode(padded.as_bytes())) - .ok()?; - let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - json.get("sub") - .or_else(|| json.get("account_id")) - .and_then(|v| v.as_str()) - .map(str::to_string) -} - -/// Look up the OpenAI bearer token sourced from the OAuth (ChatGPT -/// subscription) flow. Returns `Ok(None)` when no OAuth profile is present or -/// when the access token is empty. API-key fallback for the `openai` slug is -/// handled by the standard `lookup_key_for_slug` path — this function is -/// OAuth-only so the standard path's env/audit/metrics logic still runs. -pub fn lookup_openai_bearer_token(config: &Config) -> Result, String> { - let auth = AuthService::from_config(config); - - let profile = auth - .get_profile(OPENAI_PROVIDER_KEY, Some(OPENAI_OAUTH_PROFILE_NAME)) - .map_err(|e| e.to_string())?; - let Some(mut profile) = profile else { - return Ok(None); - }; - let Some(mut token_set) = profile.token_set.clone() else { - return Ok(None); - }; - - let skew = Duration::minutes(2); - if token_set.is_expiring_within(std::time::Duration::from_secs( - skew.num_seconds().unsigned_abs(), - )) { - if let Some(refresh) = token_set.refresh_token.clone() { - match try_refresh_oauth_token(&refresh) { - Ok(fresh) => { - token_set = token_set_from_codex(&fresh); - profile.token_set = Some(token_set.clone()); - if let Err(e) = auth_profiles_store(config).upsert_profile(profile, true) { - log::warn!( - "{LOG_PREFIX} failed to persist refreshed token: {e}; \ - fresh access token will be lost on restart" - ); - } - } - Err(e) => { - log::warn!("{LOG_PREFIX} oauth refresh failed: {e}"); - } - } - } - } - - let access = token_set.access_token.trim(); - if access.is_empty() { - Ok(None) - } else { - Ok(Some(access.to_string())) - } -} diff --git a/src/openhuman/inference/ops.rs b/src/openhuman/inference/ops.rs index 17aed26962..06459931ce 100644 --- a/src/openhuman/inference/ops.rs +++ b/src/openhuman/inference/ops.rs @@ -310,79 +310,6 @@ pub async fn inference_apply_preset(tier: &str) -> Result, Str )) } -pub async fn inference_openai_oauth_start(config: &Config) -> Result, String> { - debug!("{LOG_PREFIX} openai_oauth_start:start"); - let result = - crate::openhuman::inference::openai_oauth::start_openai_oauth(config).map(|start| { - RpcOutcome::single_log( - json!({ - "authUrl": start.auth_url, - "state": start.state, - "redirectUri": start.redirect_uri, - }), - "openai oauth authorize url ready", - ) - }); - match &result { - Ok(_) => debug!("{LOG_PREFIX} openai_oauth_start:ok"), - Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_start:error"), - } - result -} - -pub async fn inference_openai_oauth_complete( - config: &Config, - callback_url: &str, -) -> Result, String> { - debug!( - callback_len = callback_url.len(), - "{LOG_PREFIX} openai_oauth_complete:start" - ); - let result = - crate::openhuman::inference::openai_oauth::complete_openai_oauth(config, callback_url) - .await - .map(|payload| RpcOutcome::single_log(payload, "openai oauth connected")); - match &result { - Ok(_) => debug!("{LOG_PREFIX} openai_oauth_complete:ok"), - Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_complete:error"), - } - result -} - -pub async fn inference_openai_oauth_status(config: &Config) -> Result, String> { - debug!("{LOG_PREFIX} openai_oauth_status:start"); - let result = - crate::openhuman::inference::openai_oauth::openai_oauth_status(config).map(|status| { - RpcOutcome::single_log( - json!({ - "connected": status.connected, - "profileId": status.profile_id, - "expiresAt": status.expires_at, - "authMethod": status.auth_method, - }), - "openai oauth status", - ) - }); - match &result { - Ok(_) => debug!("{LOG_PREFIX} openai_oauth_status:ok"), - Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_status:error"), - } - result -} - -pub async fn inference_openai_oauth_disconnect( - config: &Config, -) -> Result, String> { - debug!("{LOG_PREFIX} openai_oauth_disconnect:start"); - let result = crate::openhuman::inference::openai_oauth::disconnect_openai_oauth(config) - .map(|payload| RpcOutcome::single_log(payload, "openai oauth disconnected")); - match &result { - Ok(_) => debug!("{LOG_PREFIX} openai_oauth_disconnect:ok"), - Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_disconnect:error"), - } - result -} - pub async fn inference_diagnostics(config: &Config) -> Result, String> { debug!("{LOG_PREFIX} diagnostics:start"); let service = local_runtime::global(config); diff --git a/src/openhuman/inference/ops_tests.rs b/src/openhuman/inference/ops_tests.rs index 771ac9d95f..7d76274b11 100644 --- a/src/openhuman/inference/ops_tests.rs +++ b/src/openhuman/inference/ops_tests.rs @@ -1,16 +1,11 @@ use super::*; -use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet}; -use crate::openhuman::inference::openai_oauth::{OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY}; -use chrono::{Duration, Utc}; use tempfile::tempdir; fn disabled_config() -> (Config, tempfile::TempDir) { let tmp = tempdir().expect("tempdir"); - let mut config = Config { - workspace_dir: tmp.path().join("workspace"), - config_path: tmp.path().join("config.toml"), - ..Config::default() - }; + let mut config = Config::default(); + config.workspace_dir = tmp.path().join("workspace"); + config.config_path = tmp.path().join("config.toml"); config.local_ai.runtime_enabled = false; config.local_ai.opt_in_confirmed = false; (config, tmp) @@ -114,99 +109,3 @@ async fn inference_presets_returns_recommended_tier() { assert!(outcome.value.get("recommended_tier").is_some()); assert!(outcome.value.get("presets").is_some()); } - -#[tokio::test] -async fn inference_openai_oauth_start_returns_authorize_payload() { - let (config, _tmp) = disabled_config(); - - let outcome = inference_openai_oauth_start(&config) - .await - .expect("oauth start"); - - assert!(outcome.value["authUrl"] - .as_str() - .unwrap() - .contains("auth.openai.com")); - assert_eq!( - outcome.value["redirectUri"].as_str(), - Some("http://127.0.0.1:1455/auth/callback") - ); - assert_eq!(outcome.logs, vec!["openai oauth authorize url ready"]); -} - -#[tokio::test] -async fn inference_openai_oauth_complete_surfaces_state_errors() { - let (config, _tmp) = disabled_config(); - let start = inference_openai_oauth_start(&config) - .await - .expect("oauth start"); - let state = start.value["state"].as_str().unwrap(); - let callback = format!("http://127.0.0.1:1455/auth/callback?code=fake&state=wrong-{state}"); - - let err = inference_openai_oauth_complete(&config, &callback) - .await - .expect_err("state mismatch should fail"); - - assert!(err.contains("state mismatch")); -} - -#[tokio::test] -async fn inference_openai_oauth_status_returns_connected_payload() { - let (config, tmp) = disabled_config(); - let store = AuthProfilesStore::new(tmp.path(), false); - store - .upsert_profile( - AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: "oauth-access".into(), - refresh_token: None, - id_token: None, - expires_at: Some(Utc::now() + Duration::hours(1)), - token_type: Some("Bearer".into()), - scope: None, - }, - ), - true, - ) - .unwrap(); - - let outcome = inference_openai_oauth_status(&config) - .await - .expect("oauth status"); - - assert_eq!(outcome.value["connected"], true); - assert_eq!(outcome.value["authMethod"], "oauth"); - assert_eq!(outcome.logs, vec!["openai oauth status"]); -} - -#[tokio::test] -async fn inference_openai_oauth_disconnect_returns_removed_flag() { - let (config, tmp) = disabled_config(); - let store = AuthProfilesStore::new(tmp.path(), false); - store - .upsert_profile( - AuthProfile::new_oauth( - OPENAI_PROVIDER_KEY, - OPENAI_OAUTH_PROFILE_NAME, - TokenSet { - access_token: "oauth-access".into(), - refresh_token: None, - id_token: None, - expires_at: None, - token_type: Some("Bearer".into()), - scope: None, - }, - ), - true, - ) - .unwrap(); - - let outcome = inference_openai_oauth_disconnect(&config) - .await - .expect("oauth disconnect"); - - assert_eq!(outcome.value["disconnected"], true); - assert_eq!(outcome.logs, vec!["openai oauth disconnected"]); -} diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index c206b0cc1a..3e961734a0 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -609,40 +609,12 @@ pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result { - log::debug!( - "[providers][chat-factory] auth lookup slug={} key_present=true (oauth)", - slug - ); - return Ok(token); - } - Ok(_) => {} - Err(e) => { - return Err(anyhow::anyhow!( - "[chat-factory] openai oauth lookup failed: {e}" - )); - } - } - } - log::debug!( - "[providers][chat-factory] auth lookup slug={} key_present=false", - slug + "[providers][chat-factory] auth lookup slug={} key_present={}", + slug, + !key.is_empty() ); - Ok(String::new()) + Ok(key) } /// Build an `OpenAiCompatibleProvider` with the given auth style. diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_test.rs index aef8420d84..97c380a023 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -605,25 +605,6 @@ fn verify_session_active_called_for_custom_provider_not_for_openhuman() { ); } -#[test] -fn lookup_key_for_slug_routes_openai_oauth_lookup_path() { - let tmp = TempDir::new().expect("tempdir"); - let config = config_in_tempdir(&tmp); - let auth = AuthService::new(tmp.path(), config.secrets.encrypt); - auth.store_provider_token( - "provider:openai", - "default", - "sk-openai", - Default::default(), - true, - ) - .expect("store openai token"); - - let token = lookup_key_for_slug("openai", &config).expect("lookup openai token"); - - assert_eq!(token, "sk-openai"); -} - // ── is_known_openhuman_tier ─────────────────────────────────────────────────── #[test] diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs index 70f70b9f67..7253c4e86a 100644 --- a/src/openhuman/inference/schemas.rs +++ b/src/openhuman/inference/schemas.rs @@ -121,12 +121,6 @@ struct InferenceApplyPresetParams { tier: String, } -#[derive(Debug, Deserialize)] -struct InferenceOpenAiOAuthCompleteParams { - #[serde(alias = "callbackUrl")] - callback_url: String, -} - pub fn all_controller_schemas() -> Vec { vec![ schemas("status"), @@ -138,10 +132,6 @@ pub fn all_controller_schemas() -> Vec { schemas("presets"), schemas("apply_preset"), schemas("diagnostics"), - schemas("openai_oauth_start"), - schemas("openai_oauth_complete"), - schemas("openai_oauth_status"), - schemas("openai_oauth_disconnect"), schemas("summarize"), schemas("prompt"), schemas("vision_prompt"), @@ -190,22 +180,6 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("diagnostics"), handler: handle_inference_diagnostics, }, - RegisteredController { - schema: schemas("openai_oauth_start"), - handler: handle_inference_openai_oauth_start, - }, - RegisteredController { - schema: schemas("openai_oauth_complete"), - handler: handle_inference_openai_oauth_complete, - }, - RegisteredController { - schema: schemas("openai_oauth_status"), - handler: handle_inference_openai_oauth_status, - }, - RegisteredController { - schema: schemas("openai_oauth_disconnect"), - handler: handle_inference_openai_oauth_disconnect, - }, RegisteredController { schema: schemas("summarize"), handler: handle_inference_summarize, @@ -339,37 +313,6 @@ pub fn schemas(function: &str) -> ControllerSchema { via `issues`.", )], }, - "openai_oauth_start" => ControllerSchema { - namespace: "inference", - function: "openai_oauth_start", - description: "Begin ChatGPT/Codex OAuth (PKCE) for the openai cloud provider.", - inputs: vec![], - outputs: vec![json_output("result", "OAuth start payload with authUrl.")], - }, - "openai_oauth_complete" => ControllerSchema { - namespace: "inference", - function: "openai_oauth_complete", - description: "Complete ChatGPT/Codex OAuth using the browser callback URL.", - inputs: vec![required_string( - "callback_url", - "Redirect URL after sign-in (http://127.0.0.1:1455/auth/callback?...).", - )], - outputs: vec![json_output("result", "OAuth completion payload.")], - }, - "openai_oauth_status" => ControllerSchema { - namespace: "inference", - function: "openai_oauth_status", - description: "Whether ChatGPT OAuth credentials are stored for openai.", - inputs: vec![], - outputs: vec![json_output("status", "OAuth connection status.")], - }, - "openai_oauth_disconnect" => ControllerSchema { - namespace: "inference", - function: "openai_oauth_disconnect", - description: "Remove stored ChatGPT OAuth credentials.", - inputs: vec![], - outputs: vec![json_output("result", "Disconnect result.")], - }, "summarize" => ControllerSchema { namespace: "inference", function: "summarize", @@ -670,41 +613,6 @@ fn handle_inference_apply_preset(params: Map) -> ControllerFuture }) } -fn handle_inference_openai_oauth_start(_params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - to_json(crate::openhuman::inference::rpc::inference_openai_oauth_start(&config).await?) - }) -} - -fn handle_inference_openai_oauth_complete(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let payload = deserialize_params::(params)?; - to_json( - crate::openhuman::inference::rpc::inference_openai_oauth_complete( - &config, - payload.callback_url.trim(), - ) - .await?, - ) - }) -} - -fn handle_inference_openai_oauth_status(_params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - to_json(crate::openhuman::inference::rpc::inference_openai_oauth_status(&config).await?) - }) -} - -fn handle_inference_openai_oauth_disconnect(_params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - to_json(crate::openhuman::inference::rpc::inference_openai_oauth_disconnect(&config).await?) - }) -} - fn handle_inference_diagnostics(_params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/inference/schemas_tests.rs b/src/openhuman/inference/schemas_tests.rs index 6085cc5882..576504701f 100644 --- a/src/openhuman/inference/schemas_tests.rs +++ b/src/openhuman/inference/schemas_tests.rs @@ -5,7 +5,7 @@ fn inference_catalog_counts_match_and_nonempty() { let declared = all_controller_schemas(); let registered = all_registered_controllers(); assert_eq!(declared.len(), registered.len()); - assert!(declared.len() >= 20); + assert!(declared.len() >= 16); } #[test] @@ -36,10 +36,6 @@ fn inference_schema_function_names_are_stable() { assert!(functions.contains(&"presets")); assert!(functions.contains(&"apply_preset")); assert!(functions.contains(&"diagnostics")); - assert!(functions.contains(&"openai_oauth_start")); - assert!(functions.contains(&"openai_oauth_complete")); - assert!(functions.contains(&"openai_oauth_status")); - assert!(functions.contains(&"openai_oauth_disconnect")); assert!(functions.contains(&"prompt")); assert!(functions.contains(&"vision_prompt")); assert!(functions.contains(&"embed")); @@ -68,45 +64,6 @@ fn inference_chat_schema_requires_messages() { .any(|field| field.name == "messages" && field.required)); } -#[test] -fn inference_openai_oauth_schemas_are_registered_with_expected_shapes() { - let registered: Vec<&str> = all_registered_controllers() - .into_iter() - .map(|controller| controller.schema.function) - .collect(); - for function in [ - "openai_oauth_start", - "openai_oauth_complete", - "openai_oauth_status", - "openai_oauth_disconnect", - ] { - assert!(registered.contains(&function), "missing {function}"); - let schema = schemas(function); - assert_eq!(schema.namespace, "inference"); - assert_eq!(schema.function, function); - assert!(!schema.description.is_empty()); - assert!(!schema.outputs.is_empty()); - } - - let complete = schemas("openai_oauth_complete"); - assert_eq!(complete.inputs.len(), 1); - assert_eq!(complete.inputs[0].name, "callback_url"); - assert!(complete.inputs[0].required); - - assert!(schemas("openai_oauth_start").inputs.is_empty()); - assert!(schemas("openai_oauth_status").inputs.is_empty()); - assert!(schemas("openai_oauth_disconnect").inputs.is_empty()); -} - -#[tokio::test] -async fn inference_openai_oauth_complete_handler_rejects_invalid_params() { - let params = Map::from_iter([("callback_url".to_string(), Value::Bool(true))]); - let err = handle_inference_openai_oauth_complete(params) - .await - .expect_err("invalid params"); - assert!(err.contains("invalid params")); -} - #[test] fn inference_unknown_schema_panics() { let panic = std::panic::catch_unwind(|| schemas("no_such_function")); diff --git a/src/openhuman/mcp_client/client.rs b/src/openhuman/mcp_client/client.rs index cfd694cc60..9e476f9700 100644 --- a/src/openhuman/mcp_client/client.rs +++ b/src/openhuman/mcp_client/client.rs @@ -385,8 +385,7 @@ impl McpHttpClient { let request = self .http .post(&self.endpoint) - .header(CONTENT_TYPE, "application/json") - .header(ACCEPT, MCP_HTTP_ACCEPT); + .header(CONTENT_TYPE, "application/json"); let request = self.apply_standard_headers(request, false, method, None, &[]); let response = request.body(serde_json::to_vec(&body)?).send().await?; let status = response.status(); @@ -436,8 +435,7 @@ impl McpHttpClient { let request = self .http .post(&self.endpoint) - .header(CONTENT_TYPE, "application/json") - .header(ACCEPT, MCP_HTTP_ACCEPT); + .header(CONTENT_TYPE, "application/json"); let request = if options.initialize { self.apply_auth(request, true) } else {