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