diff --git a/Dockerfile b/Dockerfile index 7b30b258..1174e950 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 FROM rust:1-slim-bookworm AS builder WORKDIR /build -RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/* COPY Cargo.toml Cargo.lock ./ COPY crates ./crates COPY xtask ./xtask diff --git a/crates/openfang-api/src/lib.rs b/crates/openfang-api/src/lib.rs index fc16e551..a4653917 100644 --- a/crates/openfang-api/src/lib.rs +++ b/crates/openfang-api/src/lib.rs @@ -3,6 +3,38 @@ //! Exposes agent management, status, and chat via JSON REST endpoints. //! The kernel runs in-process; the CLI connects over HTTP. +/// Decode percent-encoded strings (e.g. `%2B` → `+`). +/// Used to normalise `?token=` values that browsers encode with `encodeURIComponent`. +pub(crate) fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = ( + hex_val(bytes[i + 1]), + hex_val(bytes[i + 2]), + ) { + out.push(hi << 4 | lo); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| input.to_string()) +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + pub mod channel_bridge; pub mod middleware; pub mod openai_compat; diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index 4efb5bc3..8dc5c722 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -167,13 +167,14 @@ pub async fn auth( // Also check ?token= query parameter (for EventSource/SSE clients that // cannot set custom headers, same approach as WebSocket auth). - let query_token = request + let query_token_decoded = request .uri() .query() - .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token="))); + .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token="))) + .map(crate::percent_decode); // SECURITY: Use constant-time comparison to prevent timing attacks. - let query_auth = query_token.map(|token| { + let query_auth = query_token_decoded.as_deref().map(|token| { use subtle::ConstantTimeEq; if token.len() != api_key.len() { return false; diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index 353ffd77..095d2411 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -169,7 +169,8 @@ pub async fn agent_ws( let query_auth = uri .query() .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token="))) - .map(|token| ct_eq(token, api_key)) + .map(|raw| crate::percent_decode(raw)) + .map(|token| ct_eq(&token, api_key)) .unwrap_or(false); if !header_auth && !query_auth { diff --git a/crates/openfang-channels/src/nextcloud.rs b/crates/openfang-channels/src/nextcloud.rs index e3939254..b4856abc 100644 --- a/crates/openfang-channels/src/nextcloud.rs +++ b/crates/openfang-channels/src/nextcloud.rs @@ -260,7 +260,7 @@ impl ChannelAdapter for NextcloudAdapter { // Use lookIntoFuture=1 and lastKnownMessageId for incremental polling let url = format!( - "{}/ocs/v2.php/apps/spreed/api/v4/room/{}/chat?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}", + "{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}", server_url, room_token, last_id ); diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 8b927fa8..8bda01ca 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -7,6 +7,7 @@ use crate::think_filter::{FilterAction, StreamingThinkFilter}; use async_trait::async_trait; use futures::StreamExt; use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; +use openfang_types::model_catalog::MOONSHOT_KIMI_BASE_URL; use openfang_types::tool::ToolCall; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; @@ -84,7 +85,17 @@ impl OpenAIDriver { AZURE_API_VERSION, ) } else { - format!("{}/chat/completions", self.base_url) + // Kimi K2/K2.5 models live on api.moonshot.cn, not api.moonshot.ai. + // When the moonshot provider is configured with the default .ai URL + // but the model is a kimi-k2* model, redirect to the .cn endpoint. + let effective_url = if self.base_url.contains("api.moonshot.ai") + && model.to_lowercase().starts_with("kimi-k2") + { + MOONSHOT_KIMI_BASE_URL + } else { + &self.base_url + }; + format!("{}/chat/completions", effective_url) } } @@ -1836,4 +1847,24 @@ mod tests { let url = driver.chat_url("gpt-4o"); assert_eq!(url, "https://api.openai.com/v1/chat/completions"); } + + /// Regression test for #970: kimi-k2.5 on moonshot.ai should redirect to moonshot.cn + #[test] + fn test_kimi_k2_redirects_to_moonshot_cn() { + let driver = OpenAIDriver::new( + "test-key".to_string(), + "https://api.moonshot.ai/v1".to_string(), + ); + // kimi-k2.5 must go to the .cn endpoint + let url = driver.chat_url("kimi-k2.5"); + assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions"); + + // kimi-k2 must also redirect + let url = driver.chat_url("kimi-k2"); + assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions"); + + // moonshot-v1-128k should NOT redirect (stays on .ai) + let url = driver.chat_url("moonshot-v1-128k"); + assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions"); + } } diff --git a/crates/openfang-runtime/src/web_fetch.rs b/crates/openfang-runtime/src/web_fetch.rs index 81021aef..9fa8d622 100644 --- a/crates/openfang-runtime/src/web_fetch.rs +++ b/crates/openfang-runtime/src/web_fetch.rs @@ -514,7 +514,7 @@ mod tests { let allow = vec!["*.example.com".to_string()]; assert!(check_ssrf("http://api.example.com", &allow).is_ok()); // Non-matching domain should still go through normal checks - assert!(is_host_allowed("other.net", &allow) == false); + assert!(!is_host_allowed("other.net", &allow)); } #[test] diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index a7d2627c..8f456f10 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -45,6 +45,8 @@ pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paa pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4"; pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; +/// Kimi K2/K2.5 models live on the `.cn` platform, not `.ai`. +pub const MOONSHOT_KIMI_BASE_URL: &str = "https://api.moonshot.cn/v1"; pub const KIMI_CODING_BASE_URL: &str = "https://api.kimi.com/coding"; pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2"; pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";