From c49cf96a165acc8dfa7eccd2898501dfcd4043ad Mon Sep 17 00:00:00 2001 From: Thefool <114680394+YUZHEthefool@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:56:42 +0800 Subject: [PATCH] fix(grokbuild): complete proxy and deep-link integrations (#5677) * fix(grokbuild): complete proxy integration * fix(deeplink): preview GrokBuild configs safely * test(app): stabilize provider integration suite * fix(grokbuild): address review feedback * fix(grokbuild): resolve remaining review findings * fix(grokbuild): use native sessions and harden previews --- src-tauri/src/grok_config.rs | 10 +- src-tauri/src/proxy/session.rs | 140 +++++++++++++++-- src-tauri/src/services/env_checker.rs | 97 ++++++++++-- src/components/DeepLinkImportDialog.tsx | 73 ++------- src/components/settings/ProxyTabContent.tsx | 19 ++- src/lib/api/deeplink.ts | 11 +- src/lib/api/env.ts | 4 +- src/lib/utils/base64.ts | 13 +- src/utils/deepLinkConfigPreview.ts | 95 ++++++++++++ src/utils/deeplinkRisk.test.ts | 13 +- src/utils/deeplinkRisk.ts | 35 ++++- tests/components/ProxyTabContent.apps.test.ts | 13 ++ tests/integration/App.test.tsx | 2 +- tests/lib/modelsDevAutoSync.test.ts | 5 +- tests/msw/handlers.ts | 1 + tests/utils/deepLinkConfigPreview.test.ts | 146 ++++++++++++++++++ 16 files changed, 566 insertions(+), 111 deletions(-) create mode 100644 src/utils/deepLinkConfigPreview.ts create mode 100644 tests/components/ProxyTabContent.apps.test.ts create mode 100644 tests/utils/deepLinkConfigPreview.test.ts diff --git a/src-tauri/src/grok_config.rs b/src-tauri/src/grok_config.rs index fa1dcad65..c2b27858b 100644 --- a/src-tauri/src/grok_config.rs +++ b/src-tauri/src/grok_config.rs @@ -287,7 +287,8 @@ pub fn apply_proxy_takeover( token_placeholder: &str, ) -> Result { let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?; - update_selected_model_string(&updated, "api_key", token_placeholder) + let updated = update_selected_model_string(&updated, "api_key", token_placeholder)?; + update_selected_model_string(&updated, "api_backend", DEFAULT_API_BACKEND) } pub fn update_api_key(config_toml: &str, api_key: &str) -> Result { @@ -512,8 +513,12 @@ context_window = 500000 #[test] fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() { + let direct_config = valid_env_key_config().replace( + "api_backend = \"responses\"", + "api_backend = \"chat_completions\"", + ); let updated = apply_proxy_takeover( - valid_env_key_config(), + &direct_config, "http://127.0.0.1:15721/grokbuild/v1", "PROXY_MANAGED", ) @@ -523,6 +528,7 @@ context_window = 500000 assert_eq!(selected.profile, "grok-env"); assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY")); assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED")); + assert_eq!(selected.api_backend, DEFAULT_API_BACKEND); } #[test] diff --git a/src-tauri/src/proxy/session.rs b/src-tauri/src/proxy/session.rs index 95b726189..b44e5d3d6 100644 --- a/src-tauri/src/proxy/session.rs +++ b/src-tauri/src/proxy/session.rs @@ -7,6 +7,7 @@ //! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求: //! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取 //! - Codex: 从 headers 中的 `session_id` / `x-session-id` 或 `metadata.session_id` 提取 +//! - Grok Build: 从 headers 中的 `x-grok-conv-id` / `x-grok-session-id` 提取 //! - 其他: 生成新的 UUID use axum::http::HeaderMap; @@ -23,7 +24,7 @@ pub enum SessionIdSource { MetadataUserId, /// 从 metadata.session_id 提取 MetadataSessionId, - /// 从 headers 提取 (Codex) + /// 从 headers 提取 Header, /// 新生成 Generated, @@ -56,6 +57,11 @@ pub struct SessionIdResult { /// 2. `metadata.session_id` /// 3. 生成新 UUID /// +/// ### Grok Build 请求 +/// 1. Headers: `x-grok-conv-id` 或 `x-grok-session-id` +/// 2. `metadata.session_id` +/// 3. 生成新 UUID +/// /// ## 示例 /// /// ```ignore @@ -73,9 +79,15 @@ pub fn extract_session_id( } } - // Codex 请求特殊处理 - if client_format == "codex" || client_format == "openai" { - if let Some(result) = extract_codex_session(headers, body) { + // Responses 请求特殊处理。Grok Build 使用与 Codex 相同的客户端协议, + // 但保留独立前缀,避免统计和缓存键跨应用碰撞。 + if matches!(client_format, "codex" | "openai" | "grokbuild") { + let prefix = if client_format == "grokbuild" { + "grokbuild" + } else { + "codex" + }; + if let Some(result) = extract_responses_session(headers, body, prefix) { return result; } } @@ -111,16 +123,28 @@ fn extract_claude_session( extract_from_metadata(body) } -/// 提取 Codex Session ID -fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option { +/// 提取 Responses 客户端的 Session ID +fn extract_responses_session( + headers: &HeaderMap, + body: &serde_json::Value, + prefix: &str, +) -> Option { // 1. 从 headers 提取 - for header_name in &["session_id", "x-session-id"] { + let header_names: &[&str] = if prefix == "grokbuild" { + // Conversation ID 跨多轮请求保持稳定;session ID 作为客户端缺少 + // conversation ID 时的回退。x-grok-req-id 是逐请求 ID,不能用于聚合。 + &["x-grok-conv-id", "x-grok-session-id"] + } else { + &["session_id", "x-session-id"] + }; + for header_name in header_names { if let Some(value) = headers.get(*header_name) { if let Ok(session_id) = value.to_str() { - // Codex Session ID 通常较长(UUID 格式) + let session_id = session_id.trim(); + // Responses 客户端的 Session ID 通常较长(UUID 格式) if session_id.len() > 20 { return Some(SessionIdResult { - session_id: format!("codex_{session_id}"), + session_id: format!("{prefix}_{session_id}"), source: SessionIdSource::Header, client_provided: true, }); @@ -135,9 +159,10 @@ fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Optio .and_then(|m| m.get("session_id")) .and_then(|v| v.as_str()) { + let session_id = session_id.trim(); if session_id.len() > 10 { return Some(SessionIdResult { - session_id: format!("codex_{session_id}"), + session_id: format!("{prefix}_{session_id}"), source: SessionIdSource::MetadataSessionId, client_provided: true, }); @@ -302,6 +327,101 @@ mod tests { assert!(!result.client_provided); } + #[test] + fn test_codex_keeps_existing_response_session_headers() { + let body = json!({ "input": "Write a function" }); + + for header_name in ["session_id", "x-session-id"] { + let mut headers = HeaderMap::new(); + headers.insert( + header_name, + "d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(), + ); + + let result = extract_session_id(&headers, &body, "codex"); + + assert_eq!( + result.session_id, + "codex_d937243f-2702-4f20-97b6-c9682235ab81" + ); + assert_eq!(result.source, SessionIdSource::Header); + assert!(result.client_provided); + } + } + + #[test] + fn test_grokbuild_prefers_conversation_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-grok-conv-id", + "conv-724f4275-584e-43af-ad46-b5e7509a3ca2".parse().unwrap(), + ); + headers.insert( + "x-grok-session-id", + "session-d937243f-2702-4f20-97b6-c9682235ab81" + .parse() + .unwrap(), + ); + let body = json!({ "input": "Write a function" }); + + let result = extract_session_id(&headers, &body, "grokbuild"); + + assert_eq!( + result.session_id, + "grokbuild_conv-724f4275-584e-43af-ad46-b5e7509a3ca2" + ); + assert_eq!(result.source, SessionIdSource::Header); + assert!(result.client_provided); + } + + #[test] + fn test_grokbuild_falls_back_to_session_header() { + let body = json!({ "input": "Write a function" }); + + for conversation_id in ["", " "] { + let mut headers = HeaderMap::new(); + headers.insert("x-grok-conv-id", conversation_id.parse().unwrap()); + headers.insert( + "x-grok-session-id", + "session-d937243f-2702-4f20-97b6-c9682235ab81" + .parse() + .unwrap(), + ); + + let result = extract_session_id(&headers, &body, "grokbuild"); + + assert_eq!( + result.session_id, + "grokbuild_session-d937243f-2702-4f20-97b6-c9682235ab81" + ); + assert_eq!(result.source, SessionIdSource::Header); + assert!(result.client_provided); + } + } + + #[test] + fn test_grokbuild_ignores_request_and_codex_session_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-grok-req-id", + "request-724f4275-584e-43af-ad46-b5e7509a3ca2" + .parse() + .unwrap(), + ); + headers.insert( + "x-session-id", + "codex-d937243f-2702-4f20-97b6-c9682235ab81" + .parse() + .unwrap(), + ); + let body = json!({ "input": "Write a function" }); + + let result = extract_session_id(&headers, &body, "grokbuild"); + + assert_eq!(result.source, SessionIdSource::Generated); + assert!(!result.client_provided); + } + #[test] fn test_extract_session_generates_new_when_not_found() { let headers = HeaderMap::new(); diff --git a/src-tauri/src/services/env_checker.rs b/src-tauri/src/services/env_checker.rs index 2ed7fafdb..0c104ae8d 100644 --- a/src-tauri/src/services/env_checker.rs +++ b/src-tauri/src/services/env_checker.rs @@ -31,25 +31,46 @@ pub fn check_env_conflicts(app: &str) -> Result, String> { Ok(conflicts) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EnvKeyword { + Exact(&'static str), + Prefix(&'static str), +} + /// Get relevant keywords for each app -fn get_keywords_for_app(app: &str) -> Vec<&str> { +fn get_keywords_for_app(app: &str) -> Vec { match app.to_lowercase().as_str() { - "claude" => vec!["ANTHROPIC"], - "codex" => vec!["OPENAI"], - "gemini" => vec!["GEMINI", "GOOGLE_GEMINI"], + "claude" => vec![EnvKeyword::Prefix("ANTHROPIC")], + "codex" => vec![EnvKeyword::Prefix("OPENAI")], + "gemini" => vec![ + EnvKeyword::Prefix("GEMINI"), + EnvKeyword::Prefix("GOOGLE_GEMINI"), + ], + "grokbuild" | "grok" => vec![ + EnvKeyword::Exact("XAI_API_KEY"), + EnvKeyword::Exact("GROK_DEFAULT_MODEL"), + ], _ => vec![], } } +fn matches_env_keyword(name: &str, keywords: &[EnvKeyword]) -> bool { + let upper_name = name.to_uppercase(); + keywords.iter().any(|keyword| match keyword { + EnvKeyword::Exact(name) => upper_name == *name, + EnvKeyword::Prefix(prefix) => upper_name.starts_with(prefix), + }) +} + /// Check system environment variables (Windows Registry or Unix env) #[cfg(target_os = "windows")] -fn check_system_env(keywords: &[&str]) -> Result, String> { +fn check_system_env(keywords: &[EnvKeyword]) -> Result, String> { let mut conflicts = Vec::new(); // Check HKEY_CURRENT_USER\Environment if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") { for (name, value) in hkcu.enum_values().filter_map(Result::ok) { - if keywords.iter().any(|k| name.to_uppercase().contains(k)) { + if matches_env_keyword(&name, keywords) { conflicts.push(EnvConflict { var_name: name.clone(), var_value: value.to_string(), @@ -65,7 +86,7 @@ fn check_system_env(keywords: &[&str]) -> Result, String> { .open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment") { for (name, value) in hklm.enum_values().filter_map(Result::ok) { - if keywords.iter().any(|k| name.to_uppercase().contains(k)) { + if matches_env_keyword(&name, keywords) { conflicts.push(EnvConflict { var_name: name.clone(), var_value: value.to_string(), @@ -80,12 +101,12 @@ fn check_system_env(keywords: &[&str]) -> Result, String> { } #[cfg(not(target_os = "windows"))] -fn check_system_env(keywords: &[&str]) -> Result, String> { +fn check_system_env(keywords: &[EnvKeyword]) -> Result, String> { let mut conflicts = Vec::new(); // Check current process environment for (key, value) in std::env::vars() { - if keywords.iter().any(|k| key.to_uppercase().contains(k)) { + if matches_env_keyword(&key, keywords) { conflicts.push(EnvConflict { var_name: key, var_value: value, @@ -100,7 +121,7 @@ fn check_system_env(keywords: &[&str]) -> Result, String> { /// Check shell configuration files for environment variable exports (Unix only) #[cfg(not(target_os = "windows"))] -fn check_shell_configs(keywords: &[&str]) -> Result, String> { +fn check_shell_configs(keywords: &[EnvKeyword]) -> Result, String> { let mut conflicts = Vec::new(); let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); @@ -131,7 +152,7 @@ fn check_shell_configs(keywords: &[&str]) -> Result, String> { let var_value = export_line[eq_pos + 1..].trim(); // Check if variable name contains any keyword - if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) { + if matches_env_keyword(var_name, keywords) { conflicts.push(EnvConflict { var_name: var_name.to_string(), var_value: var_value @@ -157,12 +178,58 @@ mod tests { #[test] fn test_get_keywords() { - assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]); - assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]); + assert_eq!( + get_keywords_for_app("claude"), + vec![EnvKeyword::Prefix("ANTHROPIC")] + ); + assert_eq!( + get_keywords_for_app("codex"), + vec![EnvKeyword::Prefix("OPENAI")] + ); assert_eq!( get_keywords_for_app("gemini"), - vec!["GEMINI", "GOOGLE_GEMINI"] + vec![ + EnvKeyword::Prefix("GEMINI"), + EnvKeyword::Prefix("GOOGLE_GEMINI") + ] ); - assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new()); + assert_eq!( + get_keywords_for_app("grokbuild"), + vec![ + EnvKeyword::Exact("XAI_API_KEY"), + EnvKeyword::Exact("GROK_DEFAULT_MODEL") + ] + ); + assert_eq!( + get_keywords_for_app("grok"), + get_keywords_for_app("grokbuild") + ); + assert_eq!(get_keywords_for_app("unknown"), Vec::::new()); + } + + #[test] + fn grok_keywords_only_match_credentials() { + let keywords = get_keywords_for_app("grokbuild"); + + assert!(matches_env_keyword("XAI_API_KEY", &keywords)); + assert!(matches_env_keyword("xai_api_key", &keywords)); + assert!(matches_env_keyword("GROK_DEFAULT_MODEL", &keywords)); + assert!(matches_env_keyword("grok_default_model", &keywords)); + assert!(!matches_env_keyword("MY_XAI_API_KEY", &keywords)); + assert!(!matches_env_keyword("XAI_API_KEY_BACKUP", &keywords)); + assert!(!matches_env_keyword("MY_GROK_DEFAULT_MODEL", &keywords)); + assert!(!matches_env_keyword("GROK_DEFAULT_MODEL_BACKUP", &keywords)); + assert!(!matches_env_keyword("GROK_BIN_DIR", &keywords)); + assert!(!matches_env_keyword("GROK_HOME", &keywords)); + } + + #[test] + fn broad_app_keywords_match_only_at_the_start() { + let keywords = get_keywords_for_app("claude"); + + assert!(matches_env_keyword("ANTHROPIC_API_KEY", &keywords)); + assert!(matches_env_keyword("anthropic_base_url", &keywords)); + assert!(!matches_env_keyword("MY_ANTHROPIC_API_KEY", &keywords)); + assert!(!matches_env_keyword("NOT_ANTHROPIC", &keywords)); } } diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index b41eedb58..f2d4b669c 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo } from "react"; import { listen } from "@tauri-apps/api/event"; import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink"; +import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview"; import { Dialog, DialogContent, @@ -228,62 +229,10 @@ export function DeepLinkImportDialog() { ? "url" : null; - // Parse config file content for display - interface ParsedConfig { - type: "claude" | "codex" | "gemini"; - env?: Record; - auth?: Record; - tomlConfig?: string; - raw: Record; - } - - // Helper to decode base64 with UTF-8 support - const b64ToUtf8 = (str: string): string => { - try { - const binString = atob(str); - const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0); - return new TextDecoder().decode(bytes); - } catch (e) { - console.error("Failed to decode base64:", e); - return atob(str); - } - }; - - const parsedConfig = useMemo((): ParsedConfig | null => { - if (!request?.config) return null; - try { - const decoded = b64ToUtf8(request.config); - const parsed = JSON.parse(decoded) as Record; - - if (request.app === "claude") { - // Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } } - return { - type: "claude", - env: (parsed.env as Record) || {}, - raw: parsed, - }; - } else if (request.app === "codex") { - // Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" } - return { - type: "codex", - auth: (parsed.auth as Record) || {}, - tomlConfig: (parsed.config as string) || "", - raw: parsed, - }; - } else if (request.app === "gemini") { - // Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... } - return { - type: "gemini", - env: parsed as Record, - raw: parsed, - }; - } - return null; - } catch (e) { - console.error("Failed to parse config:", e); - return null; - } - }, [request?.config, request?.app]); + const parsedConfig = useMemo( + () => (request ? parseDeepLinkConfigPreview(request) : null), + [request], + ); /** * env 行:值经 `maskValue` 脱敏,键命中加载器控制变量时标记。 @@ -573,9 +522,11 @@ export function DeepLinkImportDialog() { )} {/* Codex config */} - {parsedConfig.type === "codex" && ( + {(parsedConfig.type === "codex" || + parsedConfig.type === "grokbuild") && (
- {parsedConfig.auth && + {parsedConfig.type === "codex" && + parsedConfig.auth && Object.keys(parsedConfig.auth).length > 0 && (
@@ -599,10 +550,8 @@ export function DeepLinkImportDialog() {
TOML Config:
-
-                                    {parsedConfig.tomlConfig.substring(0, 300)}
-                                    {parsedConfig.tomlConfig.length > 300 &&
-                                      "..."}
+                                  
+                                    {parsedConfig.tomlConfig}
                                   
)} diff --git a/src/components/settings/ProxyTabContent.tsx b/src/components/settings/ProxyTabContent.tsx index cfd332782..6f33cf6bc 100644 --- a/src/components/settings/ProxyTabContent.tsx +++ b/src/components/settings/ProxyTabContent.tsx @@ -25,6 +25,13 @@ interface ProxyTabContentProps { onAutoSave: (updates: Partial) => Promise; } +export const FAILOVER_APPS = [ + { id: "claude", label: "Claude" }, + { id: "codex", label: "Codex" }, + { id: "gemini", label: "Gemini" }, + { id: "grokbuild", label: "Grok Build" }, +] as const; + export function ProxyTabContent({ settings, onAutoSave, @@ -172,12 +179,14 @@ export function ProxyTabContent({ )} - - Claude - Codex - Gemini + + {FAILOVER_APPS.map(({ id, label }) => ( + + {label} + + ))} - {(["claude", "codex", "gemini"] as const).map((appType) => { + {FAILOVER_APPS.map(({ id: appType }) => { const failoverDisabled = !isRunning || !(takeoverStatus?.[appType] ?? false); return ( diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts index dd1d33669..66e45b540 100644 --- a/src/lib/api/deeplink.ts +++ b/src/lib/api/deeplink.ts @@ -7,7 +7,14 @@ export interface DeepLinkImportRequest { resource: ResourceType; // Common fields - app?: "claude" | "codex" | "gemini"; + app?: + | "claude" + | "codex" + | "gemini" + | "grokbuild" + | "opencode" + | "openclaw" + | "hermes"; name?: string; enabled?: boolean; @@ -27,7 +34,7 @@ export interface DeepLinkImportRequest { description?: string; // MCP fields - apps?: string; // "claude,codex,gemini" + apps?: string; // Comma-separated application IDs // Skill fields repo?: string; diff --git a/src/lib/api/env.ts b/src/lib/api/env.ts index a912f7833..958a5e912 100644 --- a/src/lib/api/env.ts +++ b/src/lib/api/env.ts @@ -7,7 +7,7 @@ import type { EnvConflict, BackupInfo } from "@/types/env"; /** * 检查指定应用的环境变量冲突 - * @param appType 应用类型 ("claude" | "codex" | "gemini") + * @param appType 应用类型 ("claude" | "codex" | "gemini" | "grokbuild") * @returns 环境变量冲突列表 */ export async function checkEnvConflicts( @@ -42,7 +42,7 @@ export async function restoreEnvBackup(backupPath: string): Promise { export async function checkAllEnvConflicts(): Promise< Record > { - const apps = ["claude", "codex", "gemini"]; + const apps = ["claude", "codex", "gemini", "grokbuild"]; const results: Record = {}; await Promise.all( diff --git a/src/lib/utils/base64.ts b/src/lib/utils/base64.ts index a49e02379..5f6e305c2 100644 --- a/src/lib/utils/base64.ts +++ b/src/lib/utils/base64.ts @@ -13,6 +13,10 @@ function toStandardBase64Alphabet(value: string): string { return value.replace(/ /g, "+").replace(/-/g, "+").replace(/_/g, "/"); } +function trimOuterLineBreaks(value: string): string { + return value.replace(/^[\r\n]+|[\r\n]+$/g, ""); +} + /** * Decode Base64 encoded UTF-8 string * @@ -27,7 +31,10 @@ function toStandardBase64Alphabet(value: string): string { */ export function decodeBase64Utf8(str: string): string { try { - let cleaned = toStandardBase64Alphabet(str.trim()); + // Keep spaces intact until they are restored to `+`. Using `trim()` here + // would discard a URL-decoded `+` at either edge and diverge from the + // backend decoder, which trims only CR/LF characters. + let cleaned = toStandardBase64Alphabet(trimOuterLineBreaks(str)); // Try to decode with standard Base64 first try { @@ -48,7 +55,9 @@ export function decodeBase64Utf8(str: string): string { console.error("Base64 decode error:", e, "Input:", str); // Last resort fallback using deprecated but sometimes working method try { - return decodeURIComponent(escape(atob(toStandardBase64Alphabet(str)))); + return decodeURIComponent( + escape(atob(toStandardBase64Alphabet(trimOuterLineBreaks(str)))), + ); } catch { // If all else fails, return original string return str; diff --git a/src/utils/deepLinkConfigPreview.ts b/src/utils/deepLinkConfigPreview.ts new file mode 100644 index 000000000..8b89a7cd5 --- /dev/null +++ b/src/utils/deepLinkConfigPreview.ts @@ -0,0 +1,95 @@ +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import type { DeepLinkImportRequest } from "@/lib/api/deeplink"; +import { decodeBase64Utf8 } from "@/lib/utils/base64"; +import { isSensitiveConfigKey, maskSensitiveValue } from "@/utils/deeplinkRisk"; + +export interface ParsedDeepLinkConfig { + type: "claude" | "codex" | "gemini" | "grokbuild"; + env?: Record; + auth?: Record; + tomlConfig?: string; +} + +const maskStructuredSecrets = ( + value: unknown, + key = "", + inheritedSensitive = false, +): unknown => { + const sensitive = inheritedSensitive || isSensitiveConfigKey(key); + if (typeof value === "string") { + return sensitive ? maskSensitiveValue(value) : value; + } + if (Array.isArray(value)) { + return value.map((item) => maskStructuredSecrets(item, key, sensitive)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map( + ([childKey, childValue]) => [ + childKey, + maskStructuredSecrets(childValue, childKey, sensitive), + ], + ), + ); + } + return value; +}; + +const sanitizeTomlForPreview = (configToml: string): string => { + const parsed = parseToml(configToml) as Record; + return `${stringifyToml(maskStructuredSecrets(parsed) as Record).trim()}\n`; +}; + +export function parseDeepLinkConfigPreview( + request: Pick, +): ParsedDeepLinkConfig | null { + if (!request.config) return null; + + try { + const decoded = decodeBase64Utf8(request.config); + const format = request.configFormat?.trim().toLowerCase(); + + if (request.app === "grokbuild" && format === "toml") { + return { + type: "grokbuild", + tomlConfig: sanitizeTomlForPreview(decoded), + }; + } + + const parsed = JSON.parse(decoded) as Record; + if (request.app === "claude") { + return { + type: "claude", + env: (parsed.env as Record) || {}, + }; + } + if (request.app === "codex") { + const config = typeof parsed.config === "string" ? parsed.config : ""; + return { + type: "codex", + auth: (parsed.auth as Record) || {}, + tomlConfig: config ? sanitizeTomlForPreview(config) : "", + }; + } + if (request.app === "gemini") { + return { + type: "gemini", + env: parsed as Record, + }; + } + if (request.app === "grokbuild") { + const config = + typeof parsed.config === "string" + ? parsed.config + : stringifyToml(parsed); + return { + type: "grokbuild", + tomlConfig: sanitizeTomlForPreview(config), + }; + } + return null; + } catch (error) { + console.error("Failed to parse deep link config preview:", error); + return null; + } +} diff --git a/src/utils/deeplinkRisk.test.ts b/src/utils/deeplinkRisk.test.ts index 958901fff..14c7fa9bc 100644 --- a/src/utils/deeplinkRisk.test.ts +++ b/src/utils/deeplinkRisk.test.ts @@ -194,12 +194,19 @@ describe("decodeDeeplinkPayload", () => { describe("maskValue", () => { it("masks credential-shaped keys but keeps ordinary values readable", () => { expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe( - "sk-ant-1************", + "sk-a************", ); expect(maskValue("ANTHROPIC_BASE_URL", "https://example.com")).toBe( "https://example.com", ); - // 短值不脱敏,否则连"是不是空的"都看不出来 - expect(maskValue("API_KEY", "short")).toBe("short"); + expect(maskValue("API_KEY", "short")).toBe("****"); + expect(maskValue("Authorization", "Basic abcd")).not.toContain("abcd"); + expect(maskValue("Cookie", "sid=1234")).toBe("****"); + expect(maskValue("Credential", "credential-value")).not.toContain( + "credential-value", + ); + expect(maskValue("auth", "short")).toBe("****"); + expect(maskValue("bearer", "short")).toBe("****"); + expect(maskValue("API_KEY", "")).toBe(""); }); }); diff --git a/src/utils/deeplinkRisk.ts b/src/utils/deeplinkRisk.ts index 69705f35d..711b9db42 100644 --- a/src/utils/deeplinkRisk.ts +++ b/src/utils/deeplinkRisk.ts @@ -7,6 +7,34 @@ export type RiskKind = "envHijack" | "privateEndpoint" | "shellCommand"; +const SENSITIVE_CONFIG_KEY_MARKERS = [ + "TOKEN", + "KEY", + "SECRET", + "PASSWORD", + "AUTHORIZATION", + "COOKIE", + "CREDENTIAL", +]; +const SENSITIVE_CONFIG_KEY_NAMES = new Set(["AUTH", "BEARER"]); + +export function isSensitiveConfigKey(key: string): boolean { + const normalizedKey = key.toUpperCase(); + return ( + SENSITIVE_CONFIG_KEY_NAMES.has(normalizedKey) || + SENSITIVE_CONFIG_KEY_MARKERS.some((marker) => + normalizedKey.includes(marker), + ) + ); +} + +export function maskSensitiveValue(value: string): string { + if (value.length === 0) return value; + return value.length > 8 + ? `${value.substring(0, 4)}${"*".repeat(12)}` + : "****"; +} + /** * 能改变子进程加载行为的环境变量。 * @@ -199,12 +227,7 @@ export function classifyCommand( * 为了两处共用同一套规则——各写一份迟早会漂移成两种脱敏口径。 */ export function maskValue(key: string, value: string): string { - const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; - const isSensitive = sensitiveKeys.some((k) => key.toUpperCase().includes(k)); - if (isSensitive && value.length > 8) { - return `${value.substring(0, 8)}${"*".repeat(12)}`; - } - return value; + return isSensitiveConfigKey(key) ? maskSensitiveValue(value) : value; } /** 风险种类 → i18n key。 */ diff --git a/tests/components/ProxyTabContent.apps.test.ts b/tests/components/ProxyTabContent.apps.test.ts new file mode 100644 index 000000000..1a8faa5a0 --- /dev/null +++ b/tests/components/ProxyTabContent.apps.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { FAILOVER_APPS } from "@/components/settings/ProxyTabContent"; + +describe("ProxyTabContent failover apps", () => { + it("exposes Grok Build alongside the existing failover applications", () => { + expect(FAILOVER_APPS.map(({ id }) => id)).toEqual([ + "claude", + "codex", + "gemini", + "grokbuild", + ]); + }); +}); diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx index 34236dd5a..bdea72e55 100644 --- a/tests/integration/App.test.tsx +++ b/tests/integration/App.test.tsx @@ -218,7 +218,7 @@ describe("App integration with MSW", () => { expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastSuccessMock).toHaveBeenCalled(); - }); + }, 10_000); it("shows toast when auto sync fails in background", async () => { const { default: App } = await import("@/App"); diff --git a/tests/lib/modelsDevAutoSync.test.ts b/tests/lib/modelsDevAutoSync.test.ts index a7256a02c..14426e019 100644 --- a/tests/lib/modelsDevAutoSync.test.ts +++ b/tests/lib/modelsDevAutoSync.test.ts @@ -82,7 +82,10 @@ describe("syncModelsDevPricing", () => { }); it("skips startup network access when pricing synced within the interval", async () => { - const lastSyncAt = Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 1; + // Keep a meaningful margin inside the interval. A 1 ms margin races the + // async mocked config lookup and makes this test depend on machine load. + const lastSyncAt = + Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 60_000; getModelsDevSyncConfig.mockResolvedValue({ ...state, config: { ...state.config, lastSyncAt }, diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 92cb9649a..61cd0005d 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -44,6 +44,7 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null), ), + http.post(`${TAURI_ENDPOINT}/list_profiles`, () => success([])), http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request); return success(getProviders(app)); diff --git a/tests/utils/deepLinkConfigPreview.test.ts b/tests/utils/deepLinkConfigPreview.test.ts new file mode 100644 index 000000000..67ea98e62 --- /dev/null +++ b/tests/utils/deepLinkConfigPreview.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview"; + +const encodeBase64 = (value: string) => + btoa(String.fromCharCode(...new TextEncoder().encode(value))); + +const encodeUrlSafeBase64WithoutPadding = (value: string) => + encodeBase64(value) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + +const grokConfig = `[models] +default = "grok-4.5" + +[model."grok-4.5"] +model = "grok-4.5" +base_url = "https://relay.example/v1" +name = "Relay" +api_key = "secret-grok-key" +api_backend = "responses" +context_window = 500000 +`; + +describe("parseDeepLinkConfigPreview", () => { + it("previews direct Grok Build TOML and masks its API key", () => { + const preview = parseDeepLinkConfigPreview({ + app: "grokbuild", + config: encodeBase64(grokConfig), + configFormat: "toml", + }); + + expect(preview?.type).toBe("grokbuild"); + expect(preview?.tomlConfig).toContain("https://relay.example/v1"); + expect(preview?.tomlConfig).toContain("secr************"); + expect(preview?.tomlConfig).not.toContain("secret-grok-key"); + }); + + it("previews wrapped Grok Build config JSON", () => { + const preview = parseDeepLinkConfigPreview({ + app: "grokbuild", + config: encodeBase64(JSON.stringify({ config: grokConfig })), + configFormat: "json", + }); + + expect(preview?.type).toBe("grokbuild"); + expect(preview?.tomlConfig).toContain('default = "grok-4.5"'); + expect(preview?.tomlConfig).not.toContain("secret-grok-key"); + }); + + it("previews URL-safe, unpadded, and space-normalized Grok Build TOML", () => { + let config = ""; + for (let shift = 0; shift < 12; shift += 1) { + config = `${grokConfig}\n# ${"p".repeat(shift)}🚀`; + const candidate = encodeBase64(config); + if (candidate.includes("+") && /=+$/.test(candidate)) break; + } + const standard = encodeBase64(config); + const encoded = encodeUrlSafeBase64WithoutPadding(config); + const encodedWithSpaces = standard.replace(/\+/g, " "); + expect(standard).toContain("+"); + expect(standard).toMatch(/=+$/); + expect(encoded).toContain("-"); + expect(encoded).not.toMatch(/=$/); + expect(encodedWithSpaces).toContain(" "); + + const urlSafePreview = parseDeepLinkConfigPreview({ + app: "grokbuild", + config: encoded, + configFormat: "toml", + }); + const spaceNormalizedPreview = parseDeepLinkConfigPreview({ + app: "grokbuild", + config: encodedWithSpaces, + configFormat: "toml", + }); + + for (const preview of [urlSafePreview, spaceNormalizedPreview]) { + expect(preview?.type).toBe("grokbuild"); + expect(preview?.tomlConfig).toContain("https://relay.example/v1"); + expect(preview?.tomlConfig).not.toContain("secret-grok-key"); + } + }); + + it("masks authentication headers in nested TOML", () => { + const config = `${grokConfig} +[mcp.servers.example] +url = "https://mcp.example" +headers = { Authorization = "Bearer top-secret", Cookie = "session=secret", credential = "credential-secret", auth = "auth-secret", safe_header = "visible" } +`; + const preview = parseDeepLinkConfigPreview({ + app: "grokbuild", + config: encodeBase64(config), + configFormat: "toml", + }); + + expect(preview?.tomlConfig).not.toContain("Bearer top-secret"); + expect(preview?.tomlConfig).not.toContain("session=secret"); + expect(preview?.tomlConfig).not.toContain("credential-secret"); + expect(preview?.tomlConfig).not.toContain("auth-secret"); + expect(preview?.tomlConfig).toContain("visible"); + }); + + it("inherits sensitivity through nested tables and arrays of tables", () => { + const config = `${grokConfig} +[model."grok-4.5".auth] +value = "nested-auth-secret" +short = "tiny" +empty = "" +nested = { value = "inline-nested-secret" } + +[[model."grok-4.5".credentials]] +value = "array-table-secret" +`; + const preview = parseDeepLinkConfigPreview({ + app: "grokbuild", + config: encodeBase64(config), + configFormat: "toml", + }); + + expect(preview?.tomlConfig).not.toContain("nested-auth-secret"); + expect(preview?.tomlConfig).not.toContain("inline-nested-secret"); + expect(preview?.tomlConfig).not.toContain("array-table-secret"); + expect(preview?.tomlConfig).toContain("nest************"); + expect(preview?.tomlConfig).toContain("inli************"); + expect(preview?.tomlConfig).toContain("arra************"); + expect(preview?.tomlConfig).toContain('short = "****"'); + expect(preview?.tomlConfig).toContain('empty = ""'); + }); + + it("also masks secrets in Codex TOML previews", () => { + const preview = parseDeepLinkConfigPreview({ + app: "codex", + config: encodeBase64( + JSON.stringify({ + auth: { OPENAI_API_KEY: "secret-auth-key" }, + config: 'experimental_bearer_token = "secret-config-key"', + }), + ), + configFormat: "json", + }); + + expect(preview?.type).toBe("codex"); + expect(preview?.tomlConfig).not.toContain("secret-config-key"); + }); +});