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
This commit is contained in:
Thefool
2026-07-31 14:56:42 +08:00
committed by GitHub
parent b884595a23
commit c49cf96a16
16 changed files with 566 additions and 111 deletions
+8 -2
View File
@@ -287,7 +287,8 @@ pub fn apply_proxy_takeover(
token_placeholder: &str, token_placeholder: &str,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?; 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<String, AppError> { pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
@@ -512,8 +513,12 @@ context_window = 500000
#[test] #[test]
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() { 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( let updated = apply_proxy_takeover(
valid_env_key_config(), &direct_config,
"http://127.0.0.1:15721/grokbuild/v1", "http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED", "PROXY_MANAGED",
) )
@@ -523,6 +528,7 @@ context_window = 500000
assert_eq!(selected.profile, "grok-env"); assert_eq!(selected.profile, "grok-env");
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY")); 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_key.as_deref(), Some("PROXY_MANAGED"));
assert_eq!(selected.api_backend, DEFAULT_API_BACKEND);
} }
#[test] #[test]
+130 -10
View File
@@ -7,6 +7,7 @@
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求: //! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取 //! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
//! - Codex: 从 headers 中的 `session_id` / `x-session-id` 或 `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 //! - 其他: 生成新的 UUID
use axum::http::HeaderMap; use axum::http::HeaderMap;
@@ -23,7 +24,7 @@ pub enum SessionIdSource {
MetadataUserId, MetadataUserId,
/// 从 metadata.session_id 提取 /// 从 metadata.session_id 提取
MetadataSessionId, MetadataSessionId,
/// 从 headers 提取 (Codex) /// 从 headers 提取
Header, Header,
/// 新生成 /// 新生成
Generated, Generated,
@@ -56,6 +57,11 @@ pub struct SessionIdResult {
/// 2. `metadata.session_id` /// 2. `metadata.session_id`
/// 3. 生成新 UUID /// 3. 生成新 UUID
/// ///
/// ### Grok Build 请求
/// 1. Headers: `x-grok-conv-id` 或 `x-grok-session-id`
/// 2. `metadata.session_id`
/// 3. 生成新 UUID
///
/// ## 示例 /// ## 示例
/// ///
/// ```ignore /// ```ignore
@@ -73,9 +79,15 @@ pub fn extract_session_id(
} }
} }
// Codex 请求特殊处理 // Responses 请求特殊处理。Grok Build 使用与 Codex 相同的客户端协议,
if client_format == "codex" || client_format == "openai" { // 但保留独立前缀,避免统计和缓存键跨应用碰撞。
if let Some(result) = extract_codex_session(headers, body) { 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; return result;
} }
} }
@@ -111,16 +123,28 @@ fn extract_claude_session(
extract_from_metadata(body) extract_from_metadata(body)
} }
/// 提取 Codex Session ID /// 提取 Responses 客户端的 Session ID
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> { fn extract_responses_session(
headers: &HeaderMap,
body: &serde_json::Value,
prefix: &str,
) -> Option<SessionIdResult> {
// 1. 从 headers 提取 // 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 Some(value) = headers.get(*header_name) {
if let Ok(session_id) = value.to_str() { 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 { if session_id.len() > 20 {
return Some(SessionIdResult { return Some(SessionIdResult {
session_id: format!("codex_{session_id}"), session_id: format!("{prefix}_{session_id}"),
source: SessionIdSource::Header, source: SessionIdSource::Header,
client_provided: true, 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(|m| m.get("session_id"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
{ {
let session_id = session_id.trim();
if session_id.len() > 10 { if session_id.len() > 10 {
return Some(SessionIdResult { return Some(SessionIdResult {
session_id: format!("codex_{session_id}"), session_id: format!("{prefix}_{session_id}"),
source: SessionIdSource::MetadataSessionId, source: SessionIdSource::MetadataSessionId,
client_provided: true, client_provided: true,
}); });
@@ -302,6 +327,101 @@ mod tests {
assert!(!result.client_provided); 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] #[test]
fn test_extract_session_generates_new_when_not_found() { fn test_extract_session_generates_new_when_not_found() {
let headers = HeaderMap::new(); let headers = HeaderMap::new();
+82 -15
View File
@@ -31,25 +31,46 @@ pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
Ok(conflicts) Ok(conflicts)
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnvKeyword {
Exact(&'static str),
Prefix(&'static str),
}
/// Get relevant keywords for each app /// Get relevant keywords for each app
fn get_keywords_for_app(app: &str) -> Vec<&str> { fn get_keywords_for_app(app: &str) -> Vec<EnvKeyword> {
match app.to_lowercase().as_str() { match app.to_lowercase().as_str() {
"claude" => vec!["ANTHROPIC"], "claude" => vec![EnvKeyword::Prefix("ANTHROPIC")],
"codex" => vec!["OPENAI"], "codex" => vec![EnvKeyword::Prefix("OPENAI")],
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"], "gemini" => vec![
EnvKeyword::Prefix("GEMINI"),
EnvKeyword::Prefix("GOOGLE_GEMINI"),
],
"grokbuild" | "grok" => vec![
EnvKeyword::Exact("XAI_API_KEY"),
EnvKeyword::Exact("GROK_DEFAULT_MODEL"),
],
_ => vec![], _ => 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) /// Check system environment variables (Windows Registry or Unix env)
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> { fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new(); let mut conflicts = Vec::new();
// Check HKEY_CURRENT_USER\Environment // Check HKEY_CURRENT_USER\Environment
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") { if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
for (name, value) in hkcu.enum_values().filter_map(Result::ok) { 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 { conflicts.push(EnvConflict {
var_name: name.clone(), var_name: name.clone(),
var_value: value.to_string(), var_value: value.to_string(),
@@ -65,7 +86,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment") .open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
{ {
for (name, value) in hklm.enum_values().filter_map(Result::ok) { 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 { conflicts.push(EnvConflict {
var_name: name.clone(), var_name: name.clone(),
var_value: value.to_string(), var_value: value.to_string(),
@@ -80,12 +101,12 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> { fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new(); let mut conflicts = Vec::new();
// Check current process environment // Check current process environment
for (key, value) in std::env::vars() { 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 { conflicts.push(EnvConflict {
var_name: key, var_name: key,
var_value: value, var_value: value,
@@ -100,7 +121,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
/// Check shell configuration files for environment variable exports (Unix only) /// Check shell configuration files for environment variable exports (Unix only)
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> { fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new(); let mut conflicts = Vec::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
@@ -131,7 +152,7 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let var_value = export_line[eq_pos + 1..].trim(); let var_value = export_line[eq_pos + 1..].trim();
// Check if variable name contains any keyword // 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 { conflicts.push(EnvConflict {
var_name: var_name.to_string(), var_name: var_name.to_string(),
var_value: var_value var_value: var_value
@@ -157,12 +178,58 @@ mod tests {
#[test] #[test]
fn test_get_keywords() { fn test_get_keywords() {
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]); assert_eq!(
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]); get_keywords_for_app("claude"),
vec![EnvKeyword::Prefix("ANTHROPIC")]
);
assert_eq!(
get_keywords_for_app("codex"),
vec![EnvKeyword::Prefix("OPENAI")]
);
assert_eq!( assert_eq!(
get_keywords_for_app("gemini"), 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::<EnvKeyword>::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));
} }
} }
+11 -62
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink"; import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -228,62 +229,10 @@ export function DeepLinkImportDialog() {
? "url" ? "url"
: null; : null;
// Parse config file content for display const parsedConfig = useMemo(
interface ParsedConfig { () => (request ? parseDeepLinkConfigPreview(request) : null),
type: "claude" | "codex" | "gemini"; [request],
env?: Record<string, string>; );
auth?: Record<string, string>;
tomlConfig?: string;
raw: Record<string, unknown>;
}
// 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<string, unknown>;
if (request.app === "claude") {
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
raw: parsed,
};
} else if (request.app === "codex") {
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
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<string, string>,
raw: parsed,
};
}
return null;
} catch (e) {
console.error("Failed to parse config:", e);
return null;
}
}, [request?.config, request?.app]);
/** /**
* env 行:值经 `maskValue` 脱敏,键命中加载器控制变量时标记。 * env 行:值经 `maskValue` 脱敏,键命中加载器控制变量时标记。
@@ -573,9 +522,11 @@ export function DeepLinkImportDialog() {
)} )}
{/* Codex config */} {/* Codex config */}
{parsedConfig.type === "codex" && ( {(parsedConfig.type === "codex" ||
parsedConfig.type === "grokbuild") && (
<div className="space-y-2"> <div className="space-y-2">
{parsedConfig.auth && {parsedConfig.type === "codex" &&
parsedConfig.auth &&
Object.keys(parsedConfig.auth).length > 0 && ( Object.keys(parsedConfig.auth).length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@@ -599,10 +550,8 @@ export function DeepLinkImportDialog() {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
TOML Config: TOML Config:
</div> </div>
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap"> <pre className="text-xs font-mono bg-background p-2 rounded overflow-auto max-h-24 whitespace-pre-wrap break-all">
{parsedConfig.tomlConfig.substring(0, 300)} {parsedConfig.tomlConfig}
{parsedConfig.tomlConfig.length > 300 &&
"..."}
</pre> </pre>
</div> </div>
)} )}
+14 -5
View File
@@ -25,6 +25,13 @@ interface ProxyTabContentProps {
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>; onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
} }
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({ export function ProxyTabContent({
settings, settings,
onAutoSave, onAutoSave,
@@ -172,12 +179,14 @@ export function ProxyTabContent({
)} )}
<Tabs defaultValue="claude" className="w-full"> <Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="claude">Claude</TabsTrigger> {FAILOVER_APPS.map(({ id, label }) => (
<TabsTrigger value="codex">Codex</TabsTrigger> <TabsTrigger key={id} value={id}>
<TabsTrigger value="gemini">Gemini</TabsTrigger> {label}
</TabsTrigger>
))}
</TabsList> </TabsList>
{(["claude", "codex", "gemini"] as const).map((appType) => { {FAILOVER_APPS.map(({ id: appType }) => {
const failoverDisabled = const failoverDisabled =
!isRunning || !(takeoverStatus?.[appType] ?? false); !isRunning || !(takeoverStatus?.[appType] ?? false);
return ( return (
+9 -2
View File
@@ -7,7 +7,14 @@ export interface DeepLinkImportRequest {
resource: ResourceType; resource: ResourceType;
// Common fields // Common fields
app?: "claude" | "codex" | "gemini"; app?:
| "claude"
| "codex"
| "gemini"
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
name?: string; name?: string;
enabled?: boolean; enabled?: boolean;
@@ -27,7 +34,7 @@ export interface DeepLinkImportRequest {
description?: string; description?: string;
// MCP fields // MCP fields
apps?: string; // "claude,codex,gemini" apps?: string; // Comma-separated application IDs
// Skill fields // Skill fields
repo?: string; repo?: string;
+2 -2
View File
@@ -7,7 +7,7 @@ import type { EnvConflict, BackupInfo } from "@/types/env";
/** /**
* 检查指定应用的环境变量冲突 * 检查指定应用的环境变量冲突
* @param appType 应用类型 ("claude" | "codex" | "gemini") * @param appType 应用类型 ("claude" | "codex" | "gemini" | "grokbuild")
* @returns 环境变量冲突列表 * @returns 环境变量冲突列表
*/ */
export async function checkEnvConflicts( export async function checkEnvConflicts(
@@ -42,7 +42,7 @@ export async function restoreEnvBackup(backupPath: string): Promise<void> {
export async function checkAllEnvConflicts(): Promise< export async function checkAllEnvConflicts(): Promise<
Record<string, EnvConflict[]> Record<string, EnvConflict[]>
> { > {
const apps = ["claude", "codex", "gemini"]; const apps = ["claude", "codex", "gemini", "grokbuild"];
const results: Record<string, EnvConflict[]> = {}; const results: Record<string, EnvConflict[]> = {};
await Promise.all( await Promise.all(
+11 -2
View File
@@ -13,6 +13,10 @@ function toStandardBase64Alphabet(value: string): string {
return value.replace(/ /g, "+").replace(/-/g, "+").replace(/_/g, "/"); 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 * Decode Base64 encoded UTF-8 string
* *
@@ -27,7 +31,10 @@ function toStandardBase64Alphabet(value: string): string {
*/ */
export function decodeBase64Utf8(str: string): string { export function decodeBase64Utf8(str: string): string {
try { 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 to decode with standard Base64 first
try { try {
@@ -48,7 +55,9 @@ export function decodeBase64Utf8(str: string): string {
console.error("Base64 decode error:", e, "Input:", str); console.error("Base64 decode error:", e, "Input:", str);
// Last resort fallback using deprecated but sometimes working method // Last resort fallback using deprecated but sometimes working method
try { try {
return decodeURIComponent(escape(atob(toStandardBase64Alphabet(str)))); return decodeURIComponent(
escape(atob(toStandardBase64Alphabet(trimOuterLineBreaks(str)))),
);
} catch { } catch {
// If all else fails, return original string // If all else fails, return original string
return str; return str;
+95
View File
@@ -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<string, string>;
auth?: Record<string, string>;
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<string, unknown>).map(
([childKey, childValue]) => [
childKey,
maskStructuredSecrets(childValue, childKey, sensitive),
],
),
);
}
return value;
};
const sanitizeTomlForPreview = (configToml: string): string => {
const parsed = parseToml(configToml) as Record<string, unknown>;
return `${stringifyToml(maskStructuredSecrets(parsed) as Record<string, unknown>).trim()}\n`;
};
export function parseDeepLinkConfigPreview(
request: Pick<DeepLinkImportRequest, "app" | "config" | "configFormat">,
): 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<string, unknown>;
if (request.app === "claude") {
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
};
}
if (request.app === "codex") {
const config = typeof parsed.config === "string" ? parsed.config : "";
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: config ? sanitizeTomlForPreview(config) : "",
};
}
if (request.app === "gemini") {
return {
type: "gemini",
env: parsed as Record<string, string>,
};
}
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;
}
}
+10 -3
View File
@@ -194,12 +194,19 @@ describe("decodeDeeplinkPayload", () => {
describe("maskValue", () => { describe("maskValue", () => {
it("masks credential-shaped keys but keeps ordinary values readable", () => { it("masks credential-shaped keys but keeps ordinary values readable", () => {
expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe( expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe(
"sk-ant-1************", "sk-a************",
); );
expect(maskValue("ANTHROPIC_BASE_URL", "https://example.com")).toBe( expect(maskValue("ANTHROPIC_BASE_URL", "https://example.com")).toBe(
"https://example.com", "https://example.com",
); );
// 短值不脱敏,否则连"是不是空的"都看不出来 expect(maskValue("API_KEY", "short")).toBe("****");
expect(maskValue("API_KEY", "short")).toBe("short"); 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("");
}); });
}); });
+29 -6
View File
@@ -7,6 +7,34 @@
export type RiskKind = "envHijack" | "privateEndpoint" | "shellCommand"; 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 { export function maskValue(key: string, value: string): string {
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; return isSensitiveConfigKey(key) ? maskSensitiveValue(value) : value;
const isSensitive = sensitiveKeys.some((k) => key.toUpperCase().includes(k));
if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`;
}
return value;
} }
/** 风险种类 → i18n key。 */ /** 风险种类 → i18n key。 */
@@ -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",
]);
});
});
+1 -1
View File
@@ -218,7 +218,7 @@ describe("App integration with MSW", () => {
expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled(); expect(toastSuccessMock).toHaveBeenCalled();
}); }, 10_000);
it("shows toast when auto sync fails in background", async () => { it("shows toast when auto sync fails in background", async () => {
const { default: App } = await import("@/App"); const { default: App } = await import("@/App");
+4 -1
View File
@@ -82,7 +82,10 @@ describe("syncModelsDevPricing", () => {
}); });
it("skips startup network access when pricing synced within the interval", async () => { 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({ getModelsDevSyncConfig.mockResolvedValue({
...state, ...state,
config: { ...state.config, lastSyncAt }, config: { ...state.config, lastSyncAt },
+1
View File
@@ -44,6 +44,7 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
success(null), success(null),
), ),
http.post(`${TAURI_ENDPOINT}/list_profiles`, () => success([])),
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => { http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request); const { app } = await withJson<{ app: AppId }>(request);
return success(getProviders(app)); return success(getProviders(app));
+146
View File
@@ -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");
});
});