- #962: WebSocket auth now URL-decodes token before comparison. API keys with +/=/
  characters (base64-derived) now work correctly for WS streaming.
- #939: Clippy bool_comparison lint fixed in web_fetch.rs test.
- #983: Dockerfile adds perl and make for openssl-sys compilation on slim-bookworm.
- #987: Nextcloud chat poll endpoint corrected from api/v4/room/{token}/chat to
  api/v1/chat/{token}/ matching the send endpoint.
- #970: Moonshot Kimi K2/K2.5 models now redirect to api.moonshot.cn/v1 instead of
  api.moonshot.ai/v1. The .ai domain only serves legacy moonshot-v1-* models.
- #882: Closed as resolved by v0.5.7 custom hand persistence fix (#984).
- #926: Verified already fixed (rmcp builder API from previous session).

All tests passing. 8 files changed, 75 insertions.
This commit is contained in:
jaberjaber23
2026-04-10 16:35:31 +03:00
parent a26f762635
commit 605ce747ec
8 changed files with 75 additions and 8 deletions

View File

@@ -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

View File

@@ -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<u8> {
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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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
);

View File

@@ -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");
}
}

View File

@@ -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]

View File

@@ -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";