feat(grokbuild): add Grok Official provider with official-state import

Add a "Grok Official" preset and seed (grokbuild-official) whose empty
config represents the official login state: no custom [model.*] tables
are written, so Grok CLI falls back to its built-in xAI OAuth login and
cc-switch never touches those credentials.

Backend:
- Seed entry in OFFICIAL_SEEDS plus ensure_grokbuild_official_provider
  command for on-demand repair (the one-shot master seeding flag is
  already set for existing databases).
- Split validation into syntax-only (empty allowed) for live reads,
  writes and official snapshots, keeping the full custom-model shape
  check for non-official provider writes and imports. Backup/restore
  can now round-trip an official-state live file.
- Manual import (command layer only) recognizes an official-state live
  config and imports it as the official entry set as current, matching
  the Codex official-login import outcome. Startup auto-import keeps
  rejecting official-state live so a deleted official entry is never
  resurrected on launch: startup import only captures real user data
  as "default" and never manufactures official entries.
- Manual import also ensures the official entry before importing
  (claude-desktop precedent) and after a successful custom import, so
  first-time users end up with default + official like other apps.
- Proxy takeover guards skip or reject official-state live configs in
  all three takeover paths, consistent with the official-provider
  takeover ban.

Frontend:
- Grok Official preset entry in the GrokBuild form: official category
  hides connection fields and passes the raw config through untouched.
- Filter managed-OAuth presets out of the GrokBuild preset list; they
  were never wired for this app and produced keyless broken configs.

Tests cover seed presence, official round-trip, ensure-after-deletion,
and the four import scenarios including startup non-resurrection.
This commit is contained in:
Jason
2026-07-21 15:31:27 +08:00
parent a5aa1fd82b
commit f733def452
16 changed files with 700 additions and 144 deletions
+51
View File
@@ -117,6 +117,46 @@ pub async fn switch_provider(
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
if matches!(app_type, AppType::GrokBuild) {
// 官方登录态(live 语法合法且无自定义模型表)+ 用户手动导入:
// 导入的正确结果是让 Grok Official 成为当前供应商,而非报错。
// 只挂在命令层 = 只有手动动作可达;启动自动导入走 service 层、
// 官方态照旧报错静默跳过,删掉的官方条目不会被重启复活
//(全项目惯例:启动自动导入只产出 default,从不产出官方条目)。
if let Ok(settings) = crate::grok_config::read_grok_live_settings() {
let config = settings
.get("config")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if crate::grok_config::is_official_live_config(config) {
state.db.ensure_official_seed_by_id(
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
AppType::GrokBuild,
)?;
state.db.set_current_provider(
app_type.as_str(),
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
)?;
crate::settings::set_current_provider(
&app_type,
Some(crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID),
)?;
return Ok(true);
}
}
// Safety net: 与 claude-desktop 导入同语义 —— 用户主动点导入是"重新
// 整理该表"的隐式信号,把官方入口补回来。覆盖导入必然失败的场景
//live 文件缺失 / TOML 语法错误 / 残缺的自定义配置),避免
// "报错 + 空列表"死胡同。失败只 warn,不影响导入主流程。
if let Err(e) = state.db.ensure_official_seed_by_id(
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
AppType::GrokBuild,
) {
log::warn!("Failed to ensure grokbuild-official seed during import: {e}");
}
}
let imported = ProviderService::import_default_config(state, app_type.clone())?;
if imported {
@@ -246,6 +286,17 @@ pub fn ensure_codex_official_provider(state: State<'_, AppState>) -> Result<bool
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn ensure_grokbuild_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
state
.db
.ensure_official_seed_by_id(
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
AppType::GrokBuild,
)
.map_err(|e| e.to_string())
}
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
let Some(env) = provider
.settings_config
+21
View File
@@ -712,6 +712,7 @@ mod ensure_official_seed_tests {
use crate::app_config::AppType;
use crate::database::{
Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
GROKBUILD_OFFICIAL_PROVIDER_ID,
};
#[test]
@@ -790,6 +791,26 @@ mod ensure_official_seed_tests {
assert_eq!(provider.settings_config["auth"], serde_json::json!({}));
}
#[test]
fn ensure_recreates_grokbuild_official_seed_after_deletion() {
let db = Database::memory().expect("memory db");
db.init_default_official_providers().expect("seed");
db.delete_provider(AppType::GrokBuild.as_str(), GROKBUILD_OFFICIAL_PROVIDER_ID)
.expect("delete Grok Build official");
let inserted = db
.ensure_official_seed_by_id(GROKBUILD_OFFICIAL_PROVIDER_ID, AppType::GrokBuild)
.expect("ensure Grok Build official");
assert!(inserted);
let provider = db
.get_provider_by_id(GROKBUILD_OFFICIAL_PROVIDER_ID, AppType::GrokBuild.as_str())
.expect("query")
.expect("Grok Build official restored");
assert_eq!(provider.category.as_deref(), Some("official"));
// 空 config:切换时不注入自定义模型表,Grok CLI 回落到自带 OAuth 登录
assert_eq!(provider.settings_config["config"], serde_json::json!(""));
}
#[test]
fn ensure_rejects_unknown_seed() {
let db = Database::memory().expect("memory db");
@@ -7,11 +7,13 @@
//! - `src/config/claudeProviderPresets.ts`"Claude Official"
//! - `src/config/codexProviderPresets.ts`"OpenAI Official"
//! - `src/config/geminiProviderPresets.ts`"Google Official"
//! - `src/components/providers/forms/GrokBuildProviderForm.tsx`"Grok Official"
use crate::app_config::AppType;
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
pub(crate) const CODEX_OFFICIAL_PROVIDER_ID: &str = "codex-official";
pub(crate) const GROKBUILD_OFFICIAL_PROVIDER_ID: &str = "grokbuild-official";
/// 单条官方供应商种子定义。
pub(crate) struct OfficialProviderSeed {
@@ -69,6 +71,16 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
// 空 env + 空 config 让用户走 Google OAuth
settings_config_json: r#"{"env":{},"config":{}}"#,
},
OfficialProviderSeed {
id: GROKBUILD_OFFICIAL_PROVIDER_ID,
app_type: AppType::GrokBuild,
name: "Grok Official",
website_url: "https://x.ai/grok",
icon: "grok",
icon_color: "currentColor",
// 空 config = 不写自定义模型表,Grok CLI 回落到自带的 xAI OAuth 登录
settings_config_json: r#"{"config":""}"#,
},
];
/// 判断给定的 provider id 是否属于内置官方种子。
@@ -92,4 +104,17 @@ mod tests {
assert_eq!(seed.app_type, AppType::ClaudeDesktop);
assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID));
}
#[test]
fn official_seeds_include_grokbuild() {
let seed = OFFICIAL_SEEDS
.iter()
.find(|seed| seed.id == GROKBUILD_OFFICIAL_PROVIDER_ID)
.expect("grok build official seed");
assert_eq!(seed.app_type, AppType::GrokBuild);
assert!(is_official_seed_id(GROKBUILD_OFFICIAL_PROVIDER_ID));
// 空 config = 官方登录态:切换时不注入自定义模型表
assert_eq!(seed.settings_config_json, r#"{"config":""}"#);
}
}
+1
View File
@@ -34,6 +34,7 @@ mod tests;
// DAO 类型导出供外部使用
pub(crate) use dao::providers_seed::{
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
GROKBUILD_OFFICIAL_PROVIDER_ID,
};
pub(crate) use dao::proxy::{
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
+126 -2
View File
@@ -59,6 +59,42 @@ fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<St
.map(ToString::to_string)
}
/// Syntax-only validation for a Grok Build config document (empty allowed).
///
/// 官方条目走 Grok CLI 自带的 xAI OAuth 登录,config.toml 不需要(通常也没有)
/// 自定义模型表:空文档合法,非空只要求 TOML 语法合法。live 层的读写与官方
/// 快照校验都用它;"必须有完整自定义模型表"的强校验见 `validate_config_toml`。
pub fn validate_config_toml_syntax(config_toml: &str) -> Result<(), AppError> {
if config_toml.trim().is_empty() {
return Ok(());
}
config_toml
.parse::<toml::Value>()
.map(|_| ())
.map_err(|error| {
AppError::localized(
"provider.grokbuild.config.invalid_toml",
format!("Grok Build config.toml 格式错误: {error}"),
format!("Invalid Grok Build config.toml: {error}"),
)
})
}
/// Whether a live config document represents the official login state.
///
/// 官方态 = 语法合法且完全没有自定义模型痕迹(无 `[models]` 也无 `[model.*]`
/// 允许 `[mcp_servers]` 等其它内容)。只要出现过任一自定义键就返回 false,
/// 让残缺的自定义配置继续走 `validate_config_toml` 报出真实错误,
/// 而不是被误判成官方态静默吞掉。语法不合法同样返回 false。
pub fn is_official_live_config(config_toml: &str) -> bool {
let Ok(document) = config_toml.parse::<toml::Value>() else {
return false;
};
document
.as_table()
.is_some_and(|root| !root.contains_key("models") && !root.contains_key("model"))
}
/// Validate the provider-owned Grok Build TOML document.
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
let document = config_toml.parse::<toml::Value>().map_err(|error| {
@@ -305,6 +341,11 @@ pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(),
Ok(())
}
/// Read the live `~/.grok/config.toml` as a provider settings snapshot.
///
/// 只做 TOML 语法校验:live 处于官方态(无自定义模型表)时同样需要能被
/// 读取,供切换回填与界面展示使用。需要"完整自定义模型配置"的导入路径
/// 由调用方自行叠加 `validate_config_toml`。
pub fn read_grok_live_settings() -> Result<Value, AppError> {
let path = get_grok_config_path();
if !path.exists() {
@@ -316,7 +357,7 @@ pub fn read_grok_live_settings() -> Result<Value, AppError> {
}
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
validate_config_toml(&config)?;
validate_config_toml_syntax(&config)?;
Ok(json!({ "config": config }))
}
@@ -339,9 +380,20 @@ pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
)
})?;
// 官方条目不注入自定义模型表:按快照原样写回(首次为空文件),
// Grok CLI 回落到官方内置模型 + 自带 OAuth 登录;MCP 投影随后由
// 切换流程重新补写。非官方供应商必须携带完整的自定义模型配置。
if provider.category.as_deref() != Some("official") {
validate_config_toml(config)?;
}
write_grok_live_settings(&json!({ "config": config }))
}
/// Raw live-file writer, mirroring `read_grok_live_settings` (syntax-only).
///
/// 代理接管的备份/恢复也走这里:官方态 live(无自定义模型表)必须可以
/// 原样写回。完整形状校验由 `write_grok_provider_live` 的非官方分支负责。
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
let config = settings
.get("config")
@@ -353,7 +405,7 @@ pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
"Grok Build configuration is missing the config field",
)
})?;
validate_config_toml(config)?;
validate_config_toml_syntax(config)?;
write_text_file(&get_grok_config_path(), config)
}
@@ -397,6 +449,32 @@ context_window = 500000
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
}
#[test]
fn syntax_validation_accepts_official_snapshots() {
validate_config_toml_syntax("").expect("empty official snapshot");
validate_config_toml_syntax("[mcp_servers.echo]\ncommand = \"echo\"\n")
.expect("official-mode config without model tables");
assert!(validate_config_toml_syntax("not = [valid").is_err());
}
#[test]
fn official_live_config_detection() {
// 官方态:完全没有自定义模型痕迹
assert!(is_official_live_config(""));
assert!(is_official_live_config(" \n# comment only\n"));
assert!(is_official_live_config(
"[mcp_servers.echo]\ncommand = \"echo\"\n"
));
// 出现过任一自定义键(哪怕残缺)都不是官方态,交给强校验报错
assert!(!is_official_live_config(valid_config()));
assert!(!is_official_live_config("[models]\ndefault = \"x\"\n"));
assert!(!is_official_live_config("[model.x]\nmodel = \"x\"\n"));
// 语法不合法不是官方态
assert!(!is_official_live_config("not = [valid"));
}
#[test]
fn rejects_missing_selected_model_table() {
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
@@ -479,6 +557,52 @@ context_window = 500000
validate_config_toml(config).expect("stripped config remains valid");
}
#[test]
#[serial]
fn official_provider_roundtrips_without_custom_model_tables() {
let temp = TempDir::new().expect("temp dir");
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
// 官方条目:空 config 可写(清掉自定义模型表,交还 Grok CLI 官方登录)
let mut official = Provider::with_id(
"grokbuild-official".to_string(),
"Grok Official".to_string(),
json!({ "config": "" }),
None,
);
official.category = Some("official".to_string());
write_grok_provider_live(&official).expect("official empty config is writable");
assert_eq!(
fs::read_to_string(get_grok_config_path()).expect("read config"),
""
);
// 官方态 live(如 MCP 投影补写后)无自定义模型表,读取与原样写回都必须可用
let official_live = "[mcp_servers.echo]\ncommand = \"echo\"\n";
write_grok_live_settings(&json!({ "config": official_live }))
.expect("official-mode live is writable for backup restore");
let settings = read_grok_live_settings().expect("official-mode live is readable");
assert_eq!(
settings.get("config").and_then(Value::as_str),
Some(official_live)
);
// 非官方供应商仍要求完整的自定义模型配置
let custom = Provider::with_id(
"custom".to_string(),
"Custom".to_string(),
json!({ "config": "" }),
None,
);
assert!(write_grok_provider_live(&custom).is_err());
match original_test_home {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
#[test]
#[serial]
fn writes_and_reads_live_config() {
+2
View File
@@ -47,6 +47,7 @@ pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::{Database, Profile};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use grok_config::get_grok_config_path;
pub use mcp::{
import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild,
remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
@@ -1316,6 +1317,7 @@ pub fn run() {
commands::import_claude_desktop_providers_from_claude,
commands::ensure_claude_desktop_official_provider,
commands::ensure_codex_official_provider,
commands::ensure_grokbuild_official_provider,
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
+24
View File
@@ -1459,6 +1459,16 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
AppType::GrokBuild => {
let mut settings = crate::grok_config::read_grok_live_settings()?;
let config = settings
.get("config")
.and_then(Value::as_str)
.unwrap_or_default();
// 官方登录态(无自定义模型表)在这里必须报错:本函数也被启动
// 自动导入调用,而全项目惯例是"启动自动导入只产出 default
// 从不产出官方条目"——否则删掉的官方条目每次重启都会复活。
// 官方态的成功导入(补官方条目并激活)只挂在手动导入的命令层
// `import_default_config_internal`)。
crate::grok_config::validate_config_toml(config)?;
crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings)?;
settings
}
@@ -1560,6 +1570,20 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
.set_current_provider(app_type.as_str(), &provider.id)?;
crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;
// 初次导入已有配置时随手补出官方入口,对齐其它应用"首启动 = 导入 default
// + 播种官方条目"的观感。grokbuild 种子晚于 `official_providers_seeded`
// flag 引入,存量库的主播种不会再跑,只能挂在导入动作上补。
// 只在导入成功时执行;live 完全不可导入(文件缺失/语法错误/残缺配置)
// 不会到达这里。失败只 warn。
if matches!(app_type, AppType::GrokBuild) {
if let Err(e) = state.db.ensure_official_seed_by_id(
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
AppType::GrokBuild,
) {
log::warn!("Failed to ensure grokbuild-official seed after import: {e}");
}
}
Ok(true) // 真正导入了
}
+7 -1
View File
@@ -3501,7 +3501,13 @@ impl ProviderService {
"Grok Build configuration is missing the config field",
)
})?;
crate::grok_config::validate_config_toml(config)?;
if provider.category.as_deref() == Some("official") {
// 官方条目走 Grok CLI 自带 OAuth:空 config 合法,
// 回填快照只要求 TOML 语法合法。
crate::grok_config::validate_config_toml_syntax(config)?;
} else {
crate::grok_config::validate_config_toml(config)?;
}
}
AppType::OpenCode => {
// OpenCode uses a different config structure: { npm, options, models }
+36 -5
View File
@@ -1502,6 +1502,19 @@ impl ProxyService {
Ok((proxy_url, proxy_codex_base_url))
}
/// Grok Build live 是否具备可接管的自定义模型表。
///
/// 官方态 liveGrok CLI 自带 OAuth 登录、无 `[model.*]` 表)没有注入
/// 占位符的落点,且官方供应商本就禁止经代理接管(封号风险),调用方
/// 应跳过接管或直接报错。
fn grok_live_config_supports_takeover(config: &Value) -> bool {
config
.get("config")
.and_then(Value::as_str)
.and_then(crate::grok_config::extract_model_config)
.is_some()
}
fn apply_grok_takeover_fields(config: &mut Value, proxy_base_url: &str) -> Result<(), String> {
let config_toml = config
.get("config")
@@ -1573,9 +1586,13 @@ impl ProxyService {
// Grok Build: keep its own provider namespace while reusing Responses forwarding.
if let Ok(mut live_config) = self.read_grok_live() {
Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?;
self.write_grok_live(&live_config)?;
log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}");
if Self::grok_live_config_supports_takeover(&live_config) {
Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?;
self.write_grok_live(&live_config)?;
log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}");
} else {
log::info!("Grok Build Live 处于官方登录态(无自定义模型表),跳过代理接管");
}
}
Ok(())
@@ -1630,6 +1647,14 @@ impl ProxyService {
}
AppType::GrokBuild => {
let mut live_config = self.read_grok_live()?;
if !Self::grok_live_config_supports_takeover(&live_config) {
return Err(
"Grok Build 当前为官方登录态(无自定义模型表),官方供应商不支持代理接管 \
(Grok Build is using the official login without a custom model table; \
official providers cannot be taken over by the proxy)"
.to_string(),
);
}
Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?;
self.write_grok_live(&live_config)?;
log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}");
@@ -1701,8 +1726,14 @@ impl ProxyService {
}
AppType::GrokBuild => {
if let Ok(mut live_config) = self.read_grok_live() {
Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?;
let _ = self.write_grok_live(&live_config);
if Self::grok_live_config_supports_takeover(&live_config) {
Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?;
let _ = self.write_grok_live(&live_config);
} else {
log::info!(
"Grok Build Live 处于官方登录态(无自定义模型表),跳过代理接管"
);
}
}
}
_ => {}
+181 -2
View File
@@ -4,9 +4,9 @@ use std::fs;
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path,
get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path, get_grok_config_path,
import_default_config_test_hook, read_claude_mcp_config, update_settings, AppError,
AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig,
AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig, ProviderService,
};
#[path = "support.rs"]
@@ -68,6 +68,185 @@ fn import_default_config_claude_persists_provider() {
);
}
#[test]
fn import_default_config_grokbuild_seeds_official_alongside_default() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let config_path = get_grok_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).expect("create grok config dir");
}
fs::write(
&config_path,
r#"[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "https://example.com/v1"
name = "Example"
api_key = "secret"
api_backend = "responses"
context_window = 500000
"#,
)
.expect("seed grok config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::GrokBuild);
let state = create_test_state_with_config(&config).expect("create test state");
import_default_config_test_hook(&state, AppType::GrokBuild)
.expect("import default config succeeds");
let providers = state
.db
.get_all_providers(AppType::GrokBuild.as_str())
.expect("get all providers");
assert!(
providers.get("default").is_some(),
"live imported as default"
);
// 初次导入已有配置时应同时补出官方入口(其它应用靠首启动主播种,
// grokbuild 种子晚于该 flag,挂在导入动作上)
let official = providers
.get("grokbuild-official")
.expect("official seed ensured alongside import");
assert_eq!(official.category.as_deref(), Some("official"));
// 激活的仍是导入的原配置,官方入口只是备选
let current_id = state
.db
.get_current_provider(AppType::GrokBuild.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("default"));
}
#[test]
fn import_default_config_grokbuild_official_live_imports_official_as_current() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// 官方登录态的 live:无自定义模型表(允许 MCP 等其它内容)。
// 导入的正确结果 = Grok Official 成为当前供应商,而非报错。
let config_path = get_grok_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).expect("create grok config dir");
}
fs::write(&config_path, "[mcp_servers.echo]\ncommand = \"echo\"\n")
.expect("seed official-mode grok config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::GrokBuild);
let state = create_test_state_with_config(&config).expect("create test state");
let imported = import_default_config_test_hook(&state, AppType::GrokBuild)
.expect("official-mode live imports as the official provider");
assert!(imported, "official-mode import should report success");
let providers = state
.db
.get_all_providers(AppType::GrokBuild.as_str())
.expect("get all providers");
let official = providers
.get("grokbuild-official")
.expect("official entry ensured by import");
assert_eq!(official.category.as_deref(), Some("official"));
assert!(
providers.get("default").is_none(),
"official-mode live must not be imported as a custom default"
);
let current_id = state
.db
.get_current_provider(AppType::GrokBuild.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("grokbuild-official"),
"official entry should become current to mirror the live state"
);
}
#[test]
fn startup_import_grokbuild_official_live_does_not_resurrect_official() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// 启动自动导入走 service 层(lib.rs 启动循环直接调用它):官方态 live
// 必须报错且不产出任何条目——全项目惯例是启动自动导入只产出 default、
// 从不产出官方条目,否则删掉的官方条目每次重启都会复活。
// 官方态的成功导入只挂在手动导入的命令层。
let config_path = get_grok_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).expect("create grok config dir");
}
fs::write(&config_path, "").expect("seed empty official-mode grok config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::GrokBuild);
let state = create_test_state_with_config(&config).expect("create test state");
ProviderService::import_default_config(&state, AppType::GrokBuild)
.expect_err("startup auto-import must not import official-mode live");
let providers = state
.db
.get_all_providers(AppType::GrokBuild.as_str())
.expect("get all providers");
assert!(
providers.is_empty(),
"startup auto-import must not create any provider from official-mode live"
);
}
#[test]
fn import_default_config_grokbuild_broken_custom_live_still_errors() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// 有自定义痕迹但残缺([models] 存在、缺 [model.*]):必须报真实错误,
// 不能被误判成官方态静默吞掉;官方入口仍由命令层前置 ensure 补出。
let config_path = get_grok_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).expect("create grok config dir");
}
fs::write(&config_path, "[models]\ndefault = \"grok-4.5\"\n")
.expect("seed broken custom grok config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::GrokBuild);
let state = create_test_state_with_config(&config).expect("create test state");
import_default_config_test_hook(&state, AppType::GrokBuild)
.expect_err("broken custom config should surface a validation error");
let providers = state
.db
.get_all_providers(AppType::GrokBuild.as_str())
.expect("get all providers");
assert!(
providers.get("grokbuild-official").is_some(),
"official entry still appears via the pre-import ensure"
);
assert!(providers.get("default").is_none(), "nothing was imported");
let current_id = state
.db
.get_current_provider(AppType::GrokBuild.as_str())
.expect("get current provider");
assert_ne!(
current_id.as_deref(),
Some("grokbuild-official"),
"failed import must not silently activate the official entry"
);
}
#[test]
fn import_default_config_without_live_file_returns_error() {
use support::create_test_state;