mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff3bc242cc |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
os: [ubuntu-22.04, windows-latest, macos-latest]
|
os: [ubuntu-22.04, windows-latest, macos-latest]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout scripts
|
- name: Checkout scripts
|
||||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
sparse-checkout: scripts
|
sparse-checkout: scripts
|
||||||
|
|
||||||
|
|||||||
@@ -1908,25 +1908,53 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
|||||||
|
|
||||||
if let Some(provider_key) = model_provider {
|
if let Some(provider_key) = model_provider {
|
||||||
// Ensure [model_providers] table exists
|
// Ensure [model_providers] table exists
|
||||||
if doc.get("model_providers").is_none() {
|
//
|
||||||
|
// 用 as_table_like_mut 而非 as_table_mut:用户把配置写成 inline table
|
||||||
|
// (`model_providers = { foo = {...} }`,TOML 合法)时 as_table_mut
|
||||||
|
// 返回 None,会一路掉进下面的顶层 fallback——用户改的 base_url 被写到
|
||||||
|
// 了错误层级且毫无提示。
|
||||||
|
if doc
|
||||||
|
.get("model_providers")
|
||||||
|
.is_none_or(|item| item.as_table_like().is_none())
|
||||||
|
{
|
||||||
|
// 键存在但不是表(`model_providers = 42`)时,下面这行会把用户
|
||||||
|
// 手写的值替换掉。旧代码在这种形状下会掉进顶层 fallback 而不动
|
||||||
|
// 它,所以归一化必须留痕——与 mcp/codex.rs、mcp/grokbuild.rs、
|
||||||
|
// opencode_config.rs 的同款处理保持一致。
|
||||||
|
if doc
|
||||||
|
.get("model_providers")
|
||||||
|
.is_some_and(|item| !item.is_none())
|
||||||
|
{
|
||||||
|
log::warn!("config.toml 的 model_providers 不是表,已重置为空表");
|
||||||
|
}
|
||||||
doc["model_providers"] = toml_edit::table();
|
doc["model_providers"] = toml_edit::table();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
|
if let Some(model_providers) = doc
|
||||||
|
.get_mut("model_providers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
{
|
||||||
// Ensure [model_providers.<provider_key>] table exists
|
// Ensure [model_providers.<provider_key>] table exists
|
||||||
if !model_providers.contains_key(&provider_key) {
|
if !model_providers.contains_key(&provider_key) {
|
||||||
model_providers[&provider_key] = toml_edit::table();
|
model_providers.insert(&provider_key, toml_edit::table());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
if let Some(provider_table) = model_providers
|
||||||
|
.get_mut(&provider_key)
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
{
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
provider_table.remove(field);
|
provider_table.remove(field);
|
||||||
} else {
|
} else {
|
||||||
provider_table[field] = toml_edit::value(trimmed);
|
provider_table.insert(field, toml_edit::value(trimmed));
|
||||||
}
|
}
|
||||||
return Ok(doc.to_string());
|
return Ok(doc.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"config.toml 的 [model_providers.{provider_key}] 结构异常,{field} 改写为顶层字段"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: no model_provider or structure mismatch → top-level field
|
// Fallback: no model_provider or structure mismatch → top-level field
|
||||||
@@ -2585,6 +2613,34 @@ model = "gpt-4"
|
|||||||
assert_eq!(base_url, "https://fallback.api/v1");
|
assert_eq!(base_url, "https://fallback.api/v1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_url_writes_into_inline_table_provider_section() {
|
||||||
|
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None。旧代码会因此
|
||||||
|
// 掉进「写顶层字段」的 fallback:用户改的 base_url 落在错误层级,
|
||||||
|
// Codex 读不到,且界面毫无提示。
|
||||||
|
let input = r#"model_provider = "any"
|
||||||
|
model_providers = { any = { name = "any", base_url = "https://old.api/v1", wire_api = "responses" } }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||||
|
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed["model_providers"]["any"]["base_url"].as_str(),
|
||||||
|
Some("https://new.api/v1"),
|
||||||
|
"must update the provider section, not a top-level field"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed.get("base_url").is_none(),
|
||||||
|
"must not leak a top-level base_url fallback"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed["model_providers"]["any"]["wire_api"].as_str(),
|
||||||
|
Some("responses"),
|
||||||
|
"sibling fields must survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clearing_base_url_removes_only_from_correct_section() {
|
fn clearing_base_url_removes_only_from_correct_section() {
|
||||||
let input = r#"model_provider = "any"
|
let input = r#"model_provider = "any"
|
||||||
|
|||||||
@@ -301,6 +301,10 @@ pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>,
|
|||||||
/// 添加技能仓库
|
/// 添加技能仓库
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
// 整个结构体由前端反序列化而来,owner/name/branch 会被拼进归档下载 URL。
|
||||||
|
// 主防线在 download_repo,这里让非法值当场报错而不是沉淀进表。
|
||||||
|
SkillService::validate_repo_ref(&repo.owner, &repo.name, &repo.branch)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.save_skill_repo(&repo)
|
.save_skill_repo(&repo)
|
||||||
|
|||||||
@@ -832,9 +832,34 @@ fn merge_grokbuild_config(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(|value| value.is_empty())
|
.is_none_or(|value| value.is_empty())
|
||||||
{
|
{
|
||||||
request.api_key = model.api_key.or_else(|| {
|
// Only inline an explicitly-declared `api_key`. Do NOT resolve `env_key`
|
||||||
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
|
// (or any process env var) into a plaintext value here: a deeplink is
|
||||||
});
|
// untrusted input, and resolving+inlining would silently persist the
|
||||||
|
// victim's environment secret into the imported provider's config.toml
|
||||||
|
// and ship it to whatever `base_url` the link declares. `env_key` is an
|
||||||
|
// indirection that must stay a name, not a resolved secret, on import.
|
||||||
|
request.api_key = model.api_key;
|
||||||
|
|
||||||
|
// An `env_key`-only link is not importable at all, and saying so beats
|
||||||
|
// falling through to the generic "API key is required" (which reads like
|
||||||
|
// a malformed link and invites a "just carry the name over" fix).
|
||||||
|
// Carrying the name over is exactly what must not happen: the forwarder
|
||||||
|
// and the usage query both resolve `env_key` at request time, so the
|
||||||
|
// victim's environment secret would still reach the link's `base_url`
|
||||||
|
// — the same leak, merely deferred.
|
||||||
|
if request
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|value| value.is_empty())
|
||||||
|
&& model.env_key.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"This link supplies its API key indirectly through `env_key`, which cannot be \
|
||||||
|
imported from an untrusted link. Add the provider manually and enter the key \
|
||||||
|
yourself."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if request
|
if request
|
||||||
.endpoint
|
.endpoint
|
||||||
@@ -929,6 +954,112 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// deeplink 同时声明 `api_key` 与 `env_key` 时,导入结果只保留用户可见的
|
||||||
|
/// `api_key`:既不能带上解析后的明文环境变量,也不能把 `env_key` 这个间接
|
||||||
|
/// 引用本身写进供应商配置。
|
||||||
|
///
|
||||||
|
/// 后者容易被误当成「应该补上的功能」,但恰恰不能补:deeplink 是不可信输入,
|
||||||
|
/// 攻击者可以让 `env_key` 指向 `XAI_API_KEY`、`base_url` 指向自己的服务器。
|
||||||
|
/// 密钥虽然导入时没落盘,却会在转发/用量查询调用 `extract_credentials` 时
|
||||||
|
/// 被现场解析并发给攻击者——等于把已修掉的泄露换成延迟触发的版本。
|
||||||
|
/// 手工建供应商的表单路径不受影响,那里的 env_key 是用户自己输入的。
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn grokbuild_deeplink_never_carries_env_key_or_resolved_secret() {
|
||||||
|
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||||||
|
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||||||
|
|
||||||
|
let mut request = DeepLinkImportRequest {
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("grokbuild".to_string()),
|
||||||
|
name: Some("Attacker".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let config = serde_json::json!({
|
||||||
|
"config": concat!(
|
||||||
|
"[models]\ndefault = \"grok-env\"\n\n",
|
||||||
|
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||||||
|
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||||||
|
"api_key = \"sk-declared-by-link\"\n",
|
||||||
|
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||||||
|
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
merge_grokbuild_config(&mut request, &config).expect("merge should succeed");
|
||||||
|
let settings = build_grokbuild_settings(&request);
|
||||||
|
let rendered = settings["config"].as_str().expect("config string");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("secret-must-not-leak"),
|
||||||
|
"the environment secret must never be inlined: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("env_key"),
|
||||||
|
"the env_key indirection must not be carried over from an untrusted link: {rendered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("api_key = \"sk-declared-by-link\""),
|
||||||
|
"only the explicitly declared api_key should survive: {rendered}"
|
||||||
|
);
|
||||||
|
|
||||||
|
match original {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `env_key` 独苗的链接必须在**公开入口**上被明确拒绝。
|
||||||
|
///
|
||||||
|
/// 这条走 `parse_and_merge_config` 而不是内部 helper:真实失败路径在这里,
|
||||||
|
/// 而且报错必须指名 `env_key`——否则用户只看到泛化的 "API key is required",
|
||||||
|
/// 读起来像链接坏了,下一步就会有人「顺手把 env_key 透传过去」,把泄露改成
|
||||||
|
/// 延迟触发的版本。
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn grokbuild_env_key_only_link_is_rejected_at_the_public_entry() {
|
||||||
|
use base64::prelude::*;
|
||||||
|
|
||||||
|
// 探针变量必须**真的设上**。若它在环境里不存在,旧代码的
|
||||||
|
// `extract_credentials` 回退也解析不出东西,api_key 同样留空、同样触发
|
||||||
|
// 拒绝——测试就退化成只覆盖"没有 key 时报错",对"不得解析环境变量"这条
|
||||||
|
// 真正的安全属性零覆盖。设上之后,一旦有人恢复回退解析,api_key 会变成
|
||||||
|
// 非空、拒绝不再触发,这条断言立刻变红。
|
||||||
|
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||||||
|
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||||||
|
|
||||||
|
let config_toml = concat!(
|
||||||
|
"[models]\ndefault = \"grok-env\"\n\n",
|
||||||
|
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||||||
|
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||||||
|
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||||||
|
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||||||
|
);
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("grokbuild".to_string()),
|
||||||
|
name: Some("Attacker".to_string()),
|
||||||
|
config: Some(BASE64_STANDARD.encode(config_toml)),
|
||||||
|
config_format: Some("toml".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = parse_and_merge_config(&request);
|
||||||
|
|
||||||
|
match original {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = result.expect_err(
|
||||||
|
"an env_key-only link must not be importable, and its env var must never be resolved",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("env_key"),
|
||||||
|
"the rejection must name env_key so it is not mistaken for a malformed link: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_hermes_settings_emits_snake_case() {
|
fn build_hermes_settings_emits_snake_case() {
|
||||||
let settings = build_hermes_settings(&hermes_request());
|
let settings = build_hermes_settings(&hermes_request());
|
||||||
|
|||||||
@@ -33,12 +33,25 @@ pub fn import_skill_from_deeplink(
|
|||||||
}
|
}
|
||||||
let owner = parts[0].to_string();
|
let owner = parts[0].to_string();
|
||||||
let name = parts[1].to_string();
|
let name = parts[1].to_string();
|
||||||
|
let branch = request.branch.unwrap_or_else(|| "main".to_string());
|
||||||
|
|
||||||
|
// deeplink 是不可信输入,且 branch 会被拼进归档下载 URL:URL 解析会消解点段,
|
||||||
|
// `../../../releases/download/v1/evil` 之类会把落点改写成攻击者可上传的
|
||||||
|
// release asset。主防线在 SkillService::download_repo,这里是入库前的纵深拦截,
|
||||||
|
// 让用户当场看到错误而不是让脏数据沉淀进 skill_repos 表。
|
||||||
|
crate::services::skill::SkillService::validate_repo_ref(&owner, &name, &branch).map_err(
|
||||||
|
|_| {
|
||||||
|
AppError::InvalidInput(format!(
|
||||||
|
"Invalid skill repository reference: '{owner}/{name}' branch '{branch}'"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
// Create SkillRepo
|
// Create SkillRepo
|
||||||
let repo = SkillRepo {
|
let repo = SkillRepo {
|
||||||
owner: owner.clone(),
|
owner: owner.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
branch: request.branch.unwrap_or_else(|| "main".to_string()),
|
branch,
|
||||||
enabled: request.enabled.unwrap_or(true),
|
enabled: request.enabled.unwrap_or(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,71 @@ pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
|||||||
Ok(parse_env_file(&content))
|
Ok(parse_env_file(&content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 .env 原文中按「键名 + 值」双匹配删除若干行,其余内容逐字保留
|
||||||
|
///
|
||||||
|
/// 不走 `parse_env_file` → `serialize_env_file` 的往返:那对函数会丢掉注释、空行、
|
||||||
|
/// 无法识别的行和重复定义,并按键名重排整个文件。全量投影时这无所谓(本来就要重写
|
||||||
|
/// 整份),但用来做**定向**清理就等于顺手把用户手写的东西一起删了。
|
||||||
|
///
|
||||||
|
/// 按值匹配而非按键名:只清掉扩散出去的那一份,用户自己写的同名不同值的行保留。
|
||||||
|
/// 同一个键有重复定义时也只删命中的那条,被它遮住的上一条会重新生效——这正是想要的
|
||||||
|
/// 结果,因为遮住它的恰恰是泄漏值。
|
||||||
|
///
|
||||||
|
/// 返回 `None` 表示没有任何一行命中,调用方据此跳过写盘。
|
||||||
|
pub fn remove_env_entries_preserving_layout(
|
||||||
|
content: &str,
|
||||||
|
doomed: &HashMap<String, String>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut removed = false;
|
||||||
|
let mut kept: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
for line in content.split('\n') {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
let hit = !trimmed.is_empty()
|
||||||
|
&& !trimmed.starts_with('#')
|
||||||
|
&& trimmed.split_once('=').is_some_and(|(key, value)| {
|
||||||
|
doomed
|
||||||
|
.get(key.trim())
|
||||||
|
.is_some_and(|doomed_value| doomed_value == value.trim())
|
||||||
|
});
|
||||||
|
|
||||||
|
if hit {
|
||||||
|
removed = true;
|
||||||
|
} else {
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed.then(|| kept.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `~/.gemini/.env` 中定向删除「键=值」完全匹配的行,返回是否真的改了文件
|
||||||
|
pub fn remove_gemini_env_entries(doomed: &HashMap<String, String>) -> Result<bool, AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
match remove_env_entries_preserving_layout(&content, doomed) {
|
||||||
|
Some(cleaned) => {
|
||||||
|
write_gemini_env_text_atomic(&cleaned)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 写入 Gemini .env 文件(原子操作)
|
/// 写入 Gemini .env 文件(原子操作)
|
||||||
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
||||||
|
write_gemini_env_text_atomic(&serialize_env_file(map))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 Gemini .env 文件(原子操作,内容逐字落盘)
|
||||||
|
///
|
||||||
|
/// 与 `write_gemini_env_atomic` 共用目录/文件权限处理,区别只在于内容不经
|
||||||
|
/// `serialize_env_file` 归一化——供保序的定向删除使用。
|
||||||
|
pub fn write_gemini_env_text_atomic(content: &str) -> Result<(), AppError> {
|
||||||
let path = get_gemini_env_path();
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
@@ -172,8 +235,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = serialize_env_file(map);
|
write_text_file(&path, content)?;
|
||||||
write_text_file(&path, &content)?;
|
|
||||||
|
|
||||||
// 设置文件权限为 600(仅所有者可读写)
|
// 设置文件权限为 600(仅所有者可读写)
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
@@ -209,21 +209,22 @@ pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
|
|||||||
|
|
||||||
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
||||||
let config = extract_model_config(config_toml)?;
|
let config = extract_model_config(config_toml)?;
|
||||||
let api_key = config
|
// Credentials only come from two explicit, config-declared sources:
|
||||||
.api_key
|
// 1. an inline `api_key`, or
|
||||||
.or_else(|| {
|
// 2. the process env var named by `env_key`.
|
||||||
|
//
|
||||||
|
// Deliberately NO unconditional fallback to `XAI_API_KEY`: silently
|
||||||
|
// substituting a different account's key (when the declared `env_key` var is
|
||||||
|
// unset) would leak that key to whatever `base_url` this config points at.
|
||||||
|
// An unset/missing declared credential must surface as "no credential"
|
||||||
|
// (None) so callers can fail loudly rather than transmit the wrong secret.
|
||||||
|
let api_key = config.api_key.or_else(|| {
|
||||||
config
|
config
|
||||||
.env_key
|
.env_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|key| std::env::var(key).ok())
|
.and_then(|key| std::env::var(key).ok())
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
std::env::var("XAI_API_KEY")
|
|
||||||
.ok()
|
|
||||||
.map(|value| value.trim().to_string())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
})?;
|
})?;
|
||||||
Some((config.base_url, api_key))
|
Some((config.base_url, api_key))
|
||||||
}
|
}
|
||||||
@@ -540,6 +541,48 @@ context_window = 500000
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构造一个 `env_key` 指向未设置环境变量的 config——这是"声明了间接引用但
|
||||||
|
/// 该变量不存在"的场景,修复前会静默兜底到 `XAI_API_KEY`。
|
||||||
|
fn env_key_unset_config() -> &'static str {
|
||||||
|
r#"[models]
|
||||||
|
default = "grok-env"
|
||||||
|
|
||||||
|
[model."grok-env"]
|
||||||
|
model = "grok-4.5"
|
||||||
|
base_url = "https://attacker.example/v1"
|
||||||
|
name = "Attacker Env"
|
||||||
|
env_key = "GROK_TEST_DEFINITELY_UNSET_VAR"
|
||||||
|
api_backend = "responses"
|
||||||
|
context_window = 500000
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn does_not_fall_back_to_xai_api_key_when_declared_env_key_is_unset() {
|
||||||
|
// 即使进程里恰好设了 XAI_API_KEY,也不能被静默借用到别的 base_url 上。
|
||||||
|
let original_xai = std::env::var_os("XAI_API_KEY");
|
||||||
|
let original_unset = std::env::var_os("GROK_TEST_DEFINITELY_UNSET_VAR");
|
||||||
|
std::env::set_var("XAI_API_KEY", "xai-secret-should-not-leak");
|
||||||
|
std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR");
|
||||||
|
|
||||||
|
let credentials = extract_credentials(env_key_unset_config());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
credentials.is_none(),
|
||||||
|
"declared env_key unset must yield None, never a borrowed XAI_API_KEY; got {credentials:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
match original_xai {
|
||||||
|
Some(value) => std::env::set_var("XAI_API_KEY", value),
|
||||||
|
None => std::env::remove_var("XAI_API_KEY"),
|
||||||
|
}
|
||||||
|
match original_unset {
|
||||||
|
Some(value) => std::env::set_var("GROK_TEST_DEFINITELY_UNSET_VAR", value),
|
||||||
|
None => std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strips_projected_mcp_servers_without_touching_model_config() {
|
fn strips_projected_mcp_servers_without_touching_model_config() {
|
||||||
let mut settings = json!({
|
let mut settings = json!({
|
||||||
|
|||||||
@@ -1184,6 +1184,17 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 必须排在 auto-extract 之前:先把历史泄漏进 Gemini 共享片段的凭据
|
||||||
|
// 清干净,否则紧接着的提取会基于被污染的 live 再写一遍。
|
||||||
|
if let Err(e) =
|
||||||
|
crate::services::provider::ProviderService::scrub_leaked_gemini_common_config(
|
||||||
|
&state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("清理 Gemini 通用配置泄漏凭据失败: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
initialize_common_config_snippets(&state);
|
initialize_common_config_snippets(&state);
|
||||||
|
|
||||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
|
|||||||
+153
-22
@@ -348,6 +348,74 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
|||||||
|
|
||||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||||
|
/// 把单个 MCP server 表写入 `[mcp_servers]`,并保证该键是「表」。
|
||||||
|
///
|
||||||
|
/// `~/.codex/config.toml` 是用户可手改的:若 `mcp_servers` 存在但不是表
|
||||||
|
/// (如 `mcp_servers = "x"` / `[]`),仅判 `contains_key` 会跳过重建,随后的
|
||||||
|
/// `doc["mcp_servers"][id] = …` 会触发 toml_edit 的 `IndexMut` panic
|
||||||
|
/// (panic 发生在 Tauri command 内、跨 FFI 展开)。这里统一归一化后再插入。
|
||||||
|
fn upsert_mcp_server_table(
|
||||||
|
doc: &mut toml_edit::DocumentMut,
|
||||||
|
id: &str,
|
||||||
|
table: toml_edit::Table,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if doc
|
||||||
|
.get_mut("mcp_servers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
// 键存在但不是表时,归一化会丢掉用户手写的那个值——必须留痕,
|
||||||
|
// 否则用户只会看到自己的改动凭空消失。
|
||||||
|
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||||
|
log::warn!("config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||||
|
}
|
||||||
|
doc["mcp_servers"] = toml_edit::table();
|
||||||
|
}
|
||||||
|
let servers = doc
|
||||||
|
.get_mut("mcp_servers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
.ok_or_else(|| AppError::McpValidation("config.toml 的 mcp_servers 不是表".to_string()))?;
|
||||||
|
servers.insert(id, toml_edit::Item::Table(table));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `[mcp_servers]`(以及历史错误格式 `[mcp.servers]`)中删除单个 MCP server。
|
||||||
|
///
|
||||||
|
/// 与 `upsert_mcp_server_table` 对称地使用 `as_table_like_mut`:用户若把配置写成
|
||||||
|
/// inline table(`mcp_servers = { foo = {...} }`,TOML 合法),`as_table_mut` 会返回
|
||||||
|
/// None 导致删除**静默失效**——界面提示已移除,条目却还在文件里,Codex 下次启动照样
|
||||||
|
/// 加载。这比 panic 更隐蔽,因为用户往往正是发现某个 MCP 有问题才来关它的。
|
||||||
|
///
|
||||||
|
/// 与写入分离成纯 doc 级函数,使守卫可脱离真实 `~/.codex/config.toml` 单测。
|
||||||
|
fn remove_mcp_server_from_doc(doc: &mut toml_edit::DocumentMut, id: &str) {
|
||||||
|
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||||
|
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||||
|
// 必须在取可变借用之前算出来。
|
||||||
|
let user_authored = !item.is_none();
|
||||||
|
match item.as_table_like_mut() {
|
||||||
|
Some(mcp_servers) => {
|
||||||
|
mcp_servers.remove(id);
|
||||||
|
}
|
||||||
|
None if user_authored => {
|
||||||
|
log::warn!("config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||||
|
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_like_mut()) {
|
||||||
|
if let Some(servers) = mcp_table
|
||||||
|
.get_mut("servers")
|
||||||
|
.and_then(|s| s.as_table_like_mut())
|
||||||
|
{
|
||||||
|
if servers.remove(id).is_some() {
|
||||||
|
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sync_single_server_to_codex(
|
pub fn sync_single_server_to_codex(
|
||||||
_config: &MultiAppConfig,
|
_config: &MultiAppConfig,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -356,7 +424,6 @@ pub fn sync_single_server_to_codex(
|
|||||||
if !should_sync_codex_mcp() {
|
if !should_sync_codex_mcp() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
use toml_edit::Item;
|
|
||||||
|
|
||||||
// 读取现有的 config.toml
|
// 读取现有的 config.toml
|
||||||
let config_path = crate::codex_config::get_codex_config_path();
|
let config_path = crate::codex_config::get_codex_config_path();
|
||||||
@@ -383,16 +450,9 @@ pub fn sync_single_server_to_codex(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 [mcp_servers] 表存在
|
|
||||||
if !doc.contains_key("mcp_servers") {
|
|
||||||
doc["mcp_servers"] = toml_edit::table();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 JSON 服务器规范转换为 TOML 表
|
// 将 JSON 服务器规范转换为 TOML 表
|
||||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||||
|
upsert_mcp_server_table(&mut doc, id, toml_table)?;
|
||||||
// 使用唯一正确的格式:[mcp_servers]
|
|
||||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
let new_text = doc.to_string();
|
let new_text = doc.to_string();
|
||||||
@@ -425,19 +485,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从正确的位置删除:[mcp_servers]
|
remove_mcp_server_from_doc(&mut doc, id);
|
||||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
|
||||||
mcp_servers.remove(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
|
||||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
|
||||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
|
||||||
if servers.remove(id).is_some() {
|
|
||||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
let new_text = doc.to_string();
|
let new_text = doc.to_string();
|
||||||
@@ -683,6 +731,89 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_normalizes_non_table_mcp_servers_without_panicking() {
|
||||||
|
// 用户手改过的 config.toml:mcp_servers 是字符串而不是表。
|
||||||
|
// 修复前 `doc["mcp_servers"][id] = …` 会 panic。
|
||||||
|
for malformed in [
|
||||||
|
"mcp_servers = \"x\"\n",
|
||||||
|
"mcp_servers = []\n",
|
||||||
|
"mcp_servers = 42\n",
|
||||||
|
] {
|
||||||
|
let mut doc = malformed
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
let table = json_server_to_toml_table(&json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx"
|
||||||
|
}))
|
||||||
|
.expect("server table");
|
||||||
|
|
||||||
|
upsert_mcp_server_table(&mut doc, "echo", table)
|
||||||
|
.unwrap_or_else(|e| panic!("upsert must not fail for {malformed:?}: {e}"));
|
||||||
|
|
||||||
|
let servers = doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.and_then(|item| item.as_table_like())
|
||||||
|
.unwrap_or_else(|| panic!("mcp_servers must be normalized to a table"));
|
||||||
|
assert!(servers.contains_key("echo"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_preserves_existing_servers_in_a_valid_table() {
|
||||||
|
let mut doc = "[mcp_servers.keep]\ncommand = \"keep\"\n"
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
let table = json_server_to_toml_table(&json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx"
|
||||||
|
}))
|
||||||
|
.expect("server table");
|
||||||
|
|
||||||
|
upsert_mcp_server_table(&mut doc, "added", table).expect("upsert");
|
||||||
|
|
||||||
|
let servers = doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.and_then(|item| item.as_table_like())
|
||||||
|
.expect("table");
|
||||||
|
assert!(servers.contains_key("keep"), "existing server must survive");
|
||||||
|
assert!(servers.contains_key("added"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_deletes_from_inline_table_form_too() {
|
||||||
|
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None——用它做守卫
|
||||||
|
// 会让删除静默失效:界面说移除成功,条目却还在,Codex 下次启动照样加载。
|
||||||
|
let mut doc = "mcp_servers = { drop = { command = \"x\" }, keep = { command = \"y\" } }\n"
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
|
||||||
|
remove_mcp_server_from_doc(&mut doc, "drop");
|
||||||
|
|
||||||
|
let servers = doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.and_then(|item| item.as_table_like())
|
||||||
|
.expect("mcp_servers must still be table-like");
|
||||||
|
assert!(
|
||||||
|
!servers.contains_key("drop"),
|
||||||
|
"removal must work on the inline-table form"
|
||||||
|
);
|
||||||
|
assert!(servers.contains_key("keep"), "siblings must survive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_is_a_noop_on_non_table_mcp_servers() {
|
||||||
|
// 既不能 panic,也不能把用户手写的值悄悄抹掉
|
||||||
|
let mut doc = "mcp_servers = 42\n"
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.expect("fixture parses");
|
||||||
|
|
||||||
|
remove_mcp_server_from_doc(&mut doc, "whatever");
|
||||||
|
|
||||||
|
assert_eq!(doc.to_string(), "mcp_servers = 42\n");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn http_headers_are_only_written_to_codex_http_headers() {
|
fn http_headers_are_only_written_to_codex_http_headers() {
|
||||||
let table = json_server_to_toml_table(&json!({
|
let table = json_server_to_toml_table(&json!({
|
||||||
|
|||||||
@@ -148,10 +148,30 @@ pub fn sync_single_server_to_grokbuild(
|
|||||||
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
if !doc.contains_key("mcp_servers") {
|
// 若 mcp_servers 缺失或存在但不是 table(如 `mcp_servers = "x"` / `[]`),
|
||||||
|
// 用空 table 归一化,避免后续 `doc["mcp_servers"][id] = …` 对非 table 索引
|
||||||
|
// 触发 toml_edit 的 IndexMut panic。用户手写的 config.toml 不可信。
|
||||||
|
// 判定走不可变的 `as_table_like`:借可变引用只为判空,会逼着下面再 get_mut 一次。
|
||||||
|
if doc
|
||||||
|
.get("mcp_servers")
|
||||||
|
.is_none_or(|item| item.as_table_like().is_none())
|
||||||
|
{
|
||||||
|
// 归一化会丢掉用户手写的那个非表值,必须留痕。
|
||||||
|
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||||
|
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||||
|
}
|
||||||
doc["mcp_servers"] = toml_edit::table();
|
doc["mcp_servers"] = toml_edit::table();
|
||||||
}
|
}
|
||||||
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
|
let servers = doc
|
||||||
|
.get_mut("mcp_servers")
|
||||||
|
.and_then(toml_edit::Item::as_table_like_mut)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::McpValidation("Grok Build config.toml 的 mcp_servers 不是表".to_string())
|
||||||
|
})?;
|
||||||
|
servers.insert(
|
||||||
|
id,
|
||||||
|
Item::Table(json_server_to_grokbuild_toml_table(server_spec)?),
|
||||||
|
);
|
||||||
crate::config::write_text_file(&path, &doc.to_string())
|
crate::config::write_text_file(&path, &doc.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,12 +191,22 @@ pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(servers) = doc
|
// 与写入侧对称使用 as_table_like_mut:inline table 形态下 as_table_mut 返回
|
||||||
.get_mut("mcp_servers")
|
// None,删除会静默失效——界面显示已移除,Grok Build 下次启动仍会加载它。
|
||||||
.and_then(toml_edit::Item::as_table_mut)
|
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||||
{
|
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||||
|
// 必须在取可变借用之前算出来。
|
||||||
|
let user_authored = !item.is_none();
|
||||||
|
match item.as_table_like_mut() {
|
||||||
|
Some(servers) => {
|
||||||
servers.remove(id);
|
servers.remove(id);
|
||||||
}
|
}
|
||||||
|
None if user_authored => {
|
||||||
|
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::config::write_text_file(&path, &doc.to_string())
|
crate::config::write_text_file(&path, &doc.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,12 +95,27 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
json5::from_str(&content).map_err(|e| {
|
let value: Value = json5::from_str(&content).map_err(|e| {
|
||||||
AppError::Config(format!(
|
AppError::Config(format!(
|
||||||
"Failed to parse OpenCode config: {}: {e}",
|
"Failed to parse OpenCode config: {}: {e}",
|
||||||
path.display()
|
path.display()
|
||||||
))
|
))
|
||||||
})
|
})?;
|
||||||
|
|
||||||
|
// 根节点必须是对象:下游 set_provider / set_mcp_server / add_plugin 都对它做
|
||||||
|
// `config["key"] = …` 索引赋值,而 serde_json 只把 Null 自动升级成对象,
|
||||||
|
// 数组或标量会直接 panic(panic 发生在 Tauri command 内、跨 FFI 展开)。
|
||||||
|
//
|
||||||
|
// 这里选择报错而不是重建根节点:opencode.json 里还有 model / theme 等用户自有
|
||||||
|
// 配置,静默重建等于删掉它们。让用户自己修文件,与 read_claude_live 的做法一致。
|
||||||
|
if !value.is_object() {
|
||||||
|
return Err(AppError::Config(format!(
|
||||||
|
"OpenCode 配置文件根节点必须是 JSON 对象: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||||
@@ -123,7 +138,13 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
|||||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
if full_config.get("provider").is_none() {
|
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到,
|
||||||
|
// 写入会静默失效——界面显示添加成功而文件里没有。provider 段是 cc-switch
|
||||||
|
// 的投影区,归一化不会碰用户自有的 model / theme 等顶层配置。
|
||||||
|
if !full_config.get("provider").is_some_and(Value::is_object) {
|
||||||
|
if full_config.get("provider").is_some() {
|
||||||
|
log::warn!("opencode.json 的 provider 不是对象,已重置为空对象");
|
||||||
|
}
|
||||||
full_config["provider"] = json!({});
|
full_config["provider"] = json!({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +163,8 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||||
providers.remove(id);
|
providers.remove(id);
|
||||||
|
} else if config.get("provider").is_some() {
|
||||||
|
log::warn!("opencode.json 的 provider 不是对象,无法删除供应商 '{id}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
@@ -182,7 +205,10 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
|||||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||||
let mut full_config = read_opencode_config()?;
|
let mut full_config = read_opencode_config()?;
|
||||||
|
|
||||||
if full_config.get("mcp").is_none() {
|
if !full_config.get("mcp").is_some_and(Value::is_object) {
|
||||||
|
if full_config.get("mcp").is_some() {
|
||||||
|
log::warn!("opencode.json 的 mcp 不是对象,已重置为空对象");
|
||||||
|
}
|
||||||
full_config["mcp"] = json!({});
|
full_config["mcp"] = json!({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +224,8 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||||
mcp.remove(id);
|
mcp.remove(id);
|
||||||
|
} else if config.get("mcp").is_some() {
|
||||||
|
log::warn!("opencode.json 的 mcp 不是对象,无法删除服务器 '{id}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
@@ -265,3 +293,77 @@ pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
|||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct TestHomeGuard(Option<std::ffi::OsString>);
|
||||||
|
impl TestHomeGuard {
|
||||||
|
fn set(home: &std::path::Path) -> Self {
|
||||||
|
let guard = Self(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||||
|
std::env::set_var("CC_SWITCH_TEST_HOME", home);
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TestHomeGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match self.0.take() {
|
||||||
|
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||||
|
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_config(home: &std::path::Path, content: &str) {
|
||||||
|
let dir = home.join(".config").join("opencode");
|
||||||
|
std::fs::create_dir_all(&dir).expect("create config dir");
|
||||||
|
std::fs::write(dir.join("opencode.json"), content).expect("write config");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn read_rejects_non_object_root_instead_of_panicking_downstream() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _guard = TestHomeGuard::set(temp.path());
|
||||||
|
|
||||||
|
// 顶层数组/标量会让下游 `config["provider"] = …` 触发 serde_json panic。
|
||||||
|
// 顶层 null 例外——serde_json 会把它自动升级成对象,本来就不炸。
|
||||||
|
for malformed in ["[]", "[{\"a\":1}]", "42", "\"oops\""] {
|
||||||
|
write_config(temp.path(), malformed);
|
||||||
|
let result = read_opencode_config();
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"non-object root must be rejected: {malformed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_config(temp.path(), "{\"model\": \"x\"}");
|
||||||
|
assert!(
|
||||||
|
read_opencode_config().is_ok(),
|
||||||
|
"a normal object config must still load"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn set_mcp_server_normalizes_non_object_section() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let _guard = TestHomeGuard::set(temp.path());
|
||||||
|
|
||||||
|
// `"mcp": []` 时旧代码的 as_object_mut 返回 None → 写入静默失效
|
||||||
|
write_config(temp.path(), "{\"model\": \"keep-me\", \"mcp\": []}");
|
||||||
|
|
||||||
|
set_mcp_server("echo", json!({"command": "npx"})).expect("set must succeed");
|
||||||
|
|
||||||
|
let config = read_opencode_config().expect("reload");
|
||||||
|
assert_eq!(
|
||||||
|
config["mcp"]["echo"]["command"], "npx",
|
||||||
|
"server must actually be written"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config["model"], "keep-me",
|
||||||
|
"unrelated user config must be preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,11 +169,23 @@ impl Provider {
|
|||||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||||
(base_url, api_key)
|
(base_url, api_key)
|
||||||
}
|
}
|
||||||
AppType::GrokBuild => settings
|
// GrokBuild 的 base_url 与 api_key 必须各自解析:extract_credentials 在
|
||||||
.get("config")
|
// 凭据缺失时整个 Option 变 None,一并 unwrap_or_default 会把明明写在
|
||||||
.and_then(Value::as_str)
|
// 配置里的 base_url 也清成空串。凭据缺失是常态(env_key 指向的变量在
|
||||||
|
// GUI 进程里读不到),端点不该被连坐——否则用量脚本的 {{baseUrl}} 变成
|
||||||
|
// 相对路径、余额查询只报「API key is empty」掩盖真因。
|
||||||
|
// 与上面 Codex 分支的写法保持一致。
|
||||||
|
AppType::GrokBuild => {
|
||||||
|
let config_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
let base_url = config_text
|
||||||
|
.and_then(crate::grok_config::extract_base_url)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let api_key = config_text
|
||||||
.and_then(crate::grok_config::extract_credentials)
|
.and_then(crate::grok_config::extract_credentials)
|
||||||
.unwrap_or_default(),
|
.map(|(_, api_key)| api_key)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(base_url, api_key)
|
||||||
|
}
|
||||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||||
AppType::Hermes => (
|
AppType::Hermes => (
|
||||||
str_at(settings.get("base_url")),
|
str_at(settings.get("base_url")),
|
||||||
|
|||||||
@@ -589,6 +589,21 @@ pub(crate) fn responses_sse_events_from_anthropic_message(
|
|||||||
tool_context: CodexToolContext,
|
tool_context: CodexToolContext,
|
||||||
) -> Vec<Bytes> {
|
) -> Vec<Bytes> {
|
||||||
let mut state = AnthropicToResponsesState::with_tool_context(tool_context);
|
let mut state = AnthropicToResponsesState::with_tool_context(tool_context);
|
||||||
|
|
||||||
|
// The whole conversion below assumes `body` is an Anthropic message object.
|
||||||
|
// A misbehaving gateway can return a top-level JSON array (or scalar) with
|
||||||
|
// HTTP 200 despite `stream:true`; index-assigning `message_start["content"]`
|
||||||
|
// on such a non-object would panic. Bail out gracefully instead.
|
||||||
|
if !body.is_object() {
|
||||||
|
return state
|
||||||
|
.failed_event(
|
||||||
|
"upstream returned a non-object Anthropic message body".to_string(),
|
||||||
|
Some("invalid_response".to_string()),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
|
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
|
||||||
let (message, error_type) = extract_anthropic_sse_error(body);
|
let (message, error_type) = extract_anthropic_sse_error(body);
|
||||||
return state
|
return state
|
||||||
@@ -819,6 +834,15 @@ mod tests {
|
|||||||
assert!(!merged.contains("stream_truncated"));
|
assert!(!merged.contains("stream_truncated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_non_object_body_returns_failed_event_not_panic() {
|
||||||
|
// A gateway that ignores `stream:true` and returns a top-level JSON array
|
||||||
|
// would have panicked on `message_start["content"] = …` before the guard.
|
||||||
|
let merged = render_message_events(&json!([1, 2, 3]));
|
||||||
|
assert!(merged.contains("event: response.failed"));
|
||||||
|
assert!(merged.contains("invalid_response"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_json_message_becomes_complete_responses_stream() {
|
fn test_json_message_becomes_complete_responses_stream() {
|
||||||
let merged = render_message_events(&json!({
|
let merged = render_message_events(&json!({
|
||||||
|
|||||||
@@ -1418,13 +1418,39 @@ pub fn anthropic_sse_to_message_value(body: &str) -> Result<Value, ProxyError> {
|
|||||||
};
|
};
|
||||||
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||||
"message_start" => {
|
"message_start" => {
|
||||||
if let Some(msg) = value.get("message") {
|
// Only accept an object message; a malformed upstream could send a
|
||||||
|
// scalar/array here, and the later `message["content"] = …` index
|
||||||
|
// assignment would panic on a non-object Value.
|
||||||
|
if let Some(msg) = value.get("message").filter(|m| m.is_object()) {
|
||||||
*message = Some(msg.clone());
|
*message = Some(msg.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"content_block_start" => {
|
"content_block_start" => {
|
||||||
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
|
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
|
||||||
let block = value.get("content_block").cloned().unwrap_or(json!({}));
|
// Sanitize to an object: any later index-assignment (`["text"]`,
|
||||||
|
// `["signature"]`, `["input"]`) requires a JSON object, so a
|
||||||
|
// malformed non-object block from the upstream cannot be stored
|
||||||
|
// verbatim (it would panic on the next delta).
|
||||||
|
//
|
||||||
|
// The replacement carries `type: "text"` rather than being empty:
|
||||||
|
// the deltas that follow are usually well-formed, and a block with
|
||||||
|
// no `type` is silently dropped by the final Responses conversion,
|
||||||
|
// which turns a garbled block header into a `completed` response
|
||||||
|
// with empty output — the client sees the model saying nothing and
|
||||||
|
// has no way to tell that data was discarded. A text block recovers
|
||||||
|
// the common case; a tool-use block still yields nothing, exactly as
|
||||||
|
// it did before.
|
||||||
|
let block = match value.get("content_block") {
|
||||||
|
Some(block) if block.is_object() => block.clone(),
|
||||||
|
malformed => {
|
||||||
|
if malformed.is_some() {
|
||||||
|
log::warn!(
|
||||||
|
"Anthropic upstream sent a non-object content_block at index {index}; recovering it as a text block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
json!({ "type": "text" })
|
||||||
|
}
|
||||||
|
};
|
||||||
blocks.insert(index, block);
|
blocks.insert(index, block);
|
||||||
json_accum.entry(index).or_default();
|
json_accum.entry(index).or_default();
|
||||||
}
|
}
|
||||||
@@ -2953,4 +2979,42 @@ data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":
|
|||||||
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
|
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
|
||||||
assert!(anthropic_sse_to_message_value(sse).is_err());
|
assert!(anthropic_sse_to_message_value(sse).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_sse_aggregation_non_object_content_block_does_not_panic() {
|
||||||
|
// A malformed upstream can send a non-object `content_block`; the index
|
||||||
|
// assignment on the next delta would have panicked before the shape guard.
|
||||||
|
let sse = concat!(
|
||||||
|
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n",
|
||||||
|
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":[1]}\n\n",
|
||||||
|
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"x\"}}\n\n",
|
||||||
|
"data: {\"type\":\"message_stop\"}\n\n",
|
||||||
|
);
|
||||||
|
let msg = anthropic_sse_to_message_value(sse)
|
||||||
|
.expect("aggregation must not panic on a non-object content_block");
|
||||||
|
assert_eq!(msg["content"][0]["text"], json!("x"));
|
||||||
|
|
||||||
|
// Not panicking is only half of it: the sanitized block must still carry a
|
||||||
|
// `type`, because the final conversion matches on it and silently drops
|
||||||
|
// anything it does not recognise. Asserting only on the intermediate value
|
||||||
|
// would pass while the client receives a `completed` response with empty
|
||||||
|
// output and no indication that the text was thrown away.
|
||||||
|
let response = anthropic_response_to_responses(msg).expect("final conversion must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
response["output"][0]["content"][0]["text"],
|
||||||
|
json!("x"),
|
||||||
|
"text recovered from a malformed block must survive to the Responses output: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_sse_aggregation_non_object_message_errors_not_panic() {
|
||||||
|
// A malformed upstream can send a scalar `message`; the later
|
||||||
|
// `message["content"] = …` would have panicked before the shape guard.
|
||||||
|
let sse = concat!(
|
||||||
|
"data: {\"type\":\"message_start\",\"message\":\"oops\"}\n\n",
|
||||||
|
"data: {\"type\":\"message_stop\"}\n\n",
|
||||||
|
);
|
||||||
|
assert!(anthropic_sse_to_message_value(sse).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -702,6 +702,469 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_gemini_common_config_strips_credentials_keeps_shareable() {
|
||||||
|
// Gemini 的共享片段会被 deep-merge 回**其它** Gemini 供应商的 env
|
||||||
|
// (live.rs::apply_common_config_to_settings),因此任何凭据都不得进入片段。
|
||||||
|
// 之前这里只硬编码跳过 GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL,而
|
||||||
|
// GOOGLE_API_KEY 是 provider.rs 认可的一等 Gemini 凭据 → 会泄露到别的供应商。
|
||||||
|
let settings = json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "g-gem",
|
||||||
|
"GOOGLE_API_KEY": "g-legacy-real-key",
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://gemini.example",
|
||||||
|
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
|
||||||
|
"SOME_PROXY_AUTH_TOKEN": "tok-proxy",
|
||||||
|
// 可共享的非机密配置必须保留
|
||||||
|
"GEMINI_TIMEOUT_MS": "30000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let snippet =
|
||||||
|
ProviderService::extract_gemini_common_config(&settings).expect("extract should work");
|
||||||
|
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||||
|
|
||||||
|
for leaked in [
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||||
|
"SOME_PROXY_AUTH_TOKEN",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
value.get(leaked).is_none(),
|
||||||
|
"credential {leaked} must not leak into the shared Gemini snippet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
value.get("GEMINI_TIMEOUT_MS").and_then(|v| v.as_str()),
|
||||||
|
Some("30000"),
|
||||||
|
"shareable non-secret config must be preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 造一个「已被污染」的现场:片段里带 A 账号的凭据 + 一个合法可共享键。
|
||||||
|
#[test]
|
||||||
|
fn sensitive_key_matcher_covers_common_credential_namings() {
|
||||||
|
for key in [
|
||||||
|
// 裸 `_KEY`:最常见的写法,却曾被"只枚举 `_API_KEY` 这些子类"漏在外面
|
||||||
|
"OPENAI_KEY",
|
||||||
|
"GROQ_KEY",
|
||||||
|
"XAI_KEY",
|
||||||
|
// 不带分隔符的复合写法
|
||||||
|
"VOLC_ACCESSKEY",
|
||||||
|
"ALIYUN_SECRETKEY",
|
||||||
|
"SOME_APITOKEN",
|
||||||
|
// personal access token:既不含 TOKEN 也不含 KEY
|
||||||
|
"GITHUB_PAT",
|
||||||
|
"gitlab_pat",
|
||||||
|
// 口令类缩写
|
||||||
|
"MYSQL_PWD",
|
||||||
|
"DB_PASS",
|
||||||
|
"GPG_PASSPHRASE",
|
||||||
|
"AWS_CREDS",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
ProviderService::is_sensitive_config_key(key),
|
||||||
|
"{key} must be treated as a credential"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后缀必须带下划线,不能把正常配置一起卷进来
|
||||||
|
for key in [
|
||||||
|
"PATH",
|
||||||
|
"OLDPWD",
|
||||||
|
"GEMINI_COMPAT",
|
||||||
|
"SSL_BYPASS",
|
||||||
|
"GEMINI_TIMEOUT_MS",
|
||||||
|
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!ProviderService::is_sensitive_config_key(key),
|
||||||
|
"{key} is ordinary shareable config and must not be stripped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_leaked_gemini_state(db: &Arc<Database>) {
|
||||||
|
db.set_config_snippet(
|
||||||
|
"gemini",
|
||||||
|
Some(
|
||||||
|
json!({
|
||||||
|
"GOOGLE_API_KEY": "key-A-leaked",
|
||||||
|
"SOME_PROXY_AUTH_TOKEN": "tok-A-leaked",
|
||||||
|
"GEMINI_TIMEOUT_MS": "30000"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("seed snippet");
|
||||||
|
|
||||||
|
// 受害者 B:泄漏的密钥已经被合并进它的 env
|
||||||
|
let victim = Provider::with_id(
|
||||||
|
"b".into(),
|
||||||
|
"Relay B".into(),
|
||||||
|
json!({ "env": {
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://relay-b.example",
|
||||||
|
"GOOGLE_API_KEY": "key-A-leaked",
|
||||||
|
"GEMINI_TIMEOUT_MS": "30000"
|
||||||
|
}}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("gemini", &victim).expect("save victim");
|
||||||
|
|
||||||
|
// 供应商 C:自己写了同名键但值不同,不能被误删
|
||||||
|
let unrelated = Provider::with_id(
|
||||||
|
"c".into(),
|
||||||
|
"Own Key C".into(),
|
||||||
|
json!({ "env": {
|
||||||
|
"GOOGLE_GEMINI_BASE_URL": "https://c.example",
|
||||||
|
"GOOGLE_API_KEY": "key-C-owned"
|
||||||
|
}}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("gemini", &unrelated).expect("save c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_removes_leaked_credentials_from_snippet_and_providers() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
// 片段:凭据清掉,可共享配置保留
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.expect("snippet must still exist");
|
||||||
|
let snippet: Value = serde_json::from_str(&snippet).expect("valid json");
|
||||||
|
assert!(snippet.get("GOOGLE_API_KEY").is_none());
|
||||||
|
assert!(snippet.get("SOME_PROXY_AUTH_TOKEN").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
snippet.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
|
||||||
|
Some("30000"),
|
||||||
|
"shareable config must survive the scrub"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 受害者 B:扩散过去的那一份被清掉
|
||||||
|
let providers = db.get_all_providers("gemini").expect("providers");
|
||||||
|
let victim_env = &providers["b"].settings_config["env"];
|
||||||
|
assert!(
|
||||||
|
victim_env.get("GOOGLE_API_KEY").is_none(),
|
||||||
|
"leaked key must be removed from the victim provider"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
victim_env.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
|
||||||
|
Some("30000"),
|
||||||
|
"non-credential config must not be touched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_keeps_a_providers_own_differently_valued_key() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
// 这条最容易写错成「按键名一刀切」:C 自己的密钥值与片段不同,是它自己的凭据
|
||||||
|
let providers = db.get_all_providers("gemini").expect("providers");
|
||||||
|
assert_eq!(
|
||||||
|
providers["c"].settings_config["env"]
|
||||||
|
.get("GOOGLE_API_KEY")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("key-C-owned"),
|
||||||
|
"a provider's own key must not be deleted by name matching"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_audit_records_key_names_but_never_values() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
let audit_text = db
|
||||||
|
.get_setting("gemini_common_config_scrub_audit_v1")
|
||||||
|
.expect("read audit")
|
||||||
|
.expect("an audit record must exist so the deletion is not silent");
|
||||||
|
|
||||||
|
// 值绝不能进这条记录:`settings` 会随 WebDAV/S3 同步上传,留值等于把一次
|
||||||
|
// 清除换成一份跨设备扩散、没有界面入口、永不过期的明文副本。
|
||||||
|
assert!(
|
||||||
|
!audit_text.contains("key-A-leaked") && !audit_text.contains("tok-A-leaked"),
|
||||||
|
"the audit record must never carry credential values: {audit_text}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 但必须说清楚删了什么、从哪删的,否则用户只能靠翻日志
|
||||||
|
let audit: Value = serde_json::from_str(&audit_text).expect("audit is JSON");
|
||||||
|
let removed: Vec<&str> = audit["removedFromSnippet"]
|
||||||
|
.as_array()
|
||||||
|
.expect("removedFromSnippet array")
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
removed.contains(&"GOOGLE_API_KEY") && removed.contains(&"SOME_PROXY_AUTH_TOKEN"),
|
||||||
|
"every key removed from the snippet must be named: {audit}"
|
||||||
|
);
|
||||||
|
let victim = audit["providers"]
|
||||||
|
.as_array()
|
||||||
|
.expect("providers array")
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry["id"] == json!("b"))
|
||||||
|
.expect("every provider whose config gets rewritten must be recorded");
|
||||||
|
assert_eq!(
|
||||||
|
victim["removedKeys"],
|
||||||
|
json!(["GOOGLE_API_KEY"]),
|
||||||
|
"the record must name what was taken from each provider: {audit}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_never_overwrites_an_existing_audit_record() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 上一轮改到一半就中止的情形:完成标记没置位,下次启动会重跑,但那时
|
||||||
|
// 读到的"原始状态"已经残缺。无条件覆盖会拿残缺记录盖掉第一轮那份完整的。
|
||||||
|
db.set_setting(
|
||||||
|
"gemini_common_config_scrub_audit_v1",
|
||||||
|
"{\"from\":\"an earlier, complete run\"}",
|
||||||
|
)
|
||||||
|
.expect("seed an existing audit record");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
db.get_setting("gemini_common_config_scrub_audit_v1")
|
||||||
|
.expect("read audit")
|
||||||
|
.as_deref(),
|
||||||
|
Some("{\"from\":\"an earlier, complete run\"}"),
|
||||||
|
"an audit record from an earlier run must survive a retry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_cleans_the_live_env_without_a_current_provider() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 没有当前供应商——这正是 sync_current_provider_for_app 直接返回 Ok 而
|
||||||
|
// 根本不写文件的分支。此时 live 若清不掉,片段又已被清空,下次切换的
|
||||||
|
// backfill 就会把残留永久写进受害供应商的配置。
|
||||||
|
crate::gemini_config::write_gemini_env_atomic(&HashMap::from([
|
||||||
|
("GOOGLE_API_KEY".to_string(), "key-A-leaked".to_string()),
|
||||||
|
("GEMINI_TIMEOUT_MS".to_string(), "30000".to_string()),
|
||||||
|
// 只存在于 live 的手工修改:定向删除必须保住它,全量重投影会抹掉
|
||||||
|
(
|
||||||
|
"HTTPS_PROXY".to_string(),
|
||||||
|
"http://127.0.0.1:7890".to_string(),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.expect("seed live env");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
let live = crate::gemini_config::read_gemini_env().expect("read live env");
|
||||||
|
assert!(
|
||||||
|
!live.contains_key("GOOGLE_API_KEY"),
|
||||||
|
"the leaked credential must be gone from ~/.gemini/.env: {live:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
live.get("HTTPS_PROXY").map(String::as_str),
|
||||||
|
Some("http://127.0.0.1:7890"),
|
||||||
|
"a hand-added live-only var must survive targeted removal: {live:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_live_cleanup_preserves_the_rest_of_the_env_file() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 这是一次用户没主动触发的启动期清理,不该顺手重写与泄漏无关的内容。
|
||||||
|
// read→HashMap→write 的往返会把注释、空行、无法识别的行全丢掉并按键名重排。
|
||||||
|
let original = "\
|
||||||
|
# my own notes
|
||||||
|
GOOGLE_API_KEY=key-C-owned
|
||||||
|
|
||||||
|
GOOGLE_API_KEY=key-A-leaked
|
||||||
|
this line is not KEY=VALUE at all
|
||||||
|
GEMINI_TIMEOUT_MS=30000
|
||||||
|
";
|
||||||
|
crate::gemini_config::write_gemini_env_text_atomic(original).expect("seed live env");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(crate::gemini_config::get_gemini_env_path())
|
||||||
|
.expect("read live env");
|
||||||
|
assert!(
|
||||||
|
!raw.contains("key-A-leaked"),
|
||||||
|
"the leaked line must be gone: {raw:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
raw.contains("# my own notes"),
|
||||||
|
"comments must survive a targeted removal: {raw:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
raw.contains("this line is not KEY=VALUE at all"),
|
||||||
|
"unparseable lines must survive a targeted removal: {raw:?}"
|
||||||
|
);
|
||||||
|
// 被泄漏值遮住的那条重新生效——正是想要的结果,遮住它的恰恰是泄漏值
|
||||||
|
assert_eq!(
|
||||||
|
crate::gemini_config::read_gemini_env()
|
||||||
|
.expect("read live env")
|
||||||
|
.get("GOOGLE_API_KEY")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("key-C-owned"),
|
||||||
|
"only the matching line may be dropped: {raw:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_aborts_before_clearing_the_snippet_when_the_live_backup_fails() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
// 关代理时这份快照会被原样写回 live。若清不动它却照样清了片段、置了完成标记,
|
||||||
|
// 代理一停凭据就复活,而一次性标记保证不会再清第二次。
|
||||||
|
db.save_live_backup("gemini", "}not json{")
|
||||||
|
.await
|
||||||
|
.expect("seed backup");
|
||||||
|
|
||||||
|
let result = ProviderService::scrub_leaked_gemini_common_config(&state).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"a backup that cannot be cleaned must abort the scrub"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 片段是「该剥哪些键」的唯一知识来源,中止后必须原样留着,否则下次重试
|
||||||
|
// 会因为 poison 为空而直接短路,反倒把标记置上
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.expect("snippet must still exist");
|
||||||
|
assert!(
|
||||||
|
snippet.contains("key-A-leaked"),
|
||||||
|
"the snippet must be left intact so the next boot can retry: {snippet}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.get_setting("gemini_common_config_credentials_scrubbed_v1")
|
||||||
|
.expect("read flag")
|
||||||
|
.is_none(),
|
||||||
|
"the one-shot flag must not be set when the scrub aborted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_leaves_no_residue_for_backfill_to_persist() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("scrub must succeed");
|
||||||
|
|
||||||
|
// 顺序陷阱回归:如果只清了片段,切走供应商时 remove_common_config_from_settings
|
||||||
|
// 就不再认识这个键,live 里的残留会被 backfill 永久写进供应商配置。
|
||||||
|
// 清理必须是原子的——清完之后,任何地方都不该再有那个值。
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert!(!snippet.contains("key-A-leaked"));
|
||||||
|
|
||||||
|
for (id, provider) in db.get_all_providers("gemini").expect("providers") {
|
||||||
|
assert!(
|
||||||
|
!provider
|
||||||
|
.settings_config
|
||||||
|
.to_string()
|
||||||
|
.contains("key-A-leaked"),
|
||||||
|
"provider '{id}' still carries the leaked value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn scrub_gemini_is_idempotent_and_skips_on_second_run() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
seed_leaked_gemini_state(&db);
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("first run");
|
||||||
|
|
||||||
|
// 第二次必须是 no-op:用户清理后重新填的凭据不能被再抹一遍
|
||||||
|
db.set_config_snippet(
|
||||||
|
"gemini",
|
||||||
|
Some(json!({"GOOGLE_API_KEY": "restored"}).to_string()),
|
||||||
|
)
|
||||||
|
.expect("user re-adds a value");
|
||||||
|
|
||||||
|
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||||
|
.await
|
||||||
|
.expect("second run");
|
||||||
|
|
||||||
|
let snippet = db
|
||||||
|
.get_config_snippet("gemini")
|
||||||
|
.expect("read snippet")
|
||||||
|
.expect("snippet exists");
|
||||||
|
assert!(
|
||||||
|
snippet.contains("restored"),
|
||||||
|
"the one-shot flag must prevent a second scrub: {snippet}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
|
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
|
||||||
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)
|
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)
|
||||||
@@ -3029,20 +3492,39 @@ impl ProviderService {
|
|||||||
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
||||||
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
||||||
/// 私钥命名。
|
/// 私钥命名。
|
||||||
fn is_sensitive_config_key(name: &str) -> bool {
|
pub(crate) fn is_sensitive_config_key(name: &str) -> bool {
|
||||||
let upper = name.to_ascii_uppercase();
|
let upper = name.to_ascii_uppercase();
|
||||||
|
|
||||||
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
||||||
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
||||||
const SENSITIVE_SUFFIXES: &[&str] = &[
|
const SENSITIVE_SUFFIXES: &[&str] = &[
|
||||||
|
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),
|
||||||
|
// 必须单列:只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通
|
||||||
|
// 的那一种漏在外面。下面几条 `_*_KEY` 被它蕴含,保留是为了说明覆盖面。
|
||||||
|
"_KEY",
|
||||||
"_API_KEY",
|
"_API_KEY",
|
||||||
"_APIKEY",
|
|
||||||
"_AUTH_TOKEN",
|
|
||||||
"_TOKEN",
|
|
||||||
"_ACCESS_KEY",
|
"_ACCESS_KEY",
|
||||||
"_ACCESS_KEY_ID",
|
"_ACCESS_KEY_ID",
|
||||||
"_KEY_ID",
|
"_KEY_ID",
|
||||||
"_PRIVATE_KEY",
|
"_PRIVATE_KEY",
|
||||||
|
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`
|
||||||
|
// (倒数第四个字符是 I 不是下划线)。VOLC_ACCESSKEY 是火山引擎文档
|
||||||
|
// 里的正式变量名,本仓库就实现了火山 AK/SK 用量查询。
|
||||||
|
"_APIKEY",
|
||||||
|
"_ACCESSKEY",
|
||||||
|
"_SECRETKEY",
|
||||||
|
"_APITOKEN",
|
||||||
|
"_AUTH_TOKEN",
|
||||||
|
"_TOKEN",
|
||||||
|
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
|
||||||
|
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
|
||||||
|
"_PAT",
|
||||||
|
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`(那个以 `_BYPASS`
|
||||||
|
// 结尾),`_PWD` 也不会误伤 shell 的 PWD / OLDPWD。
|
||||||
|
"_PWD",
|
||||||
|
"_PASS",
|
||||||
|
"_PASSPHRASE",
|
||||||
|
"_CREDS",
|
||||||
];
|
];
|
||||||
const SENSITIVE_EXACT: &[&str] = &[
|
const SENSITIVE_EXACT: &[&str] = &[
|
||||||
"APIKEY",
|
"APIKEY",
|
||||||
@@ -3242,7 +3724,13 @@ impl ProviderService {
|
|||||||
let mut snippet = serde_json::Map::new();
|
let mut snippet = serde_json::Map::new();
|
||||||
if let Some(env) = env {
|
if let Some(env) = env {
|
||||||
for (key, value) in env {
|
for (key, value) in env {
|
||||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
// 端点按名剥离(它不是凭据,模式匹配够不着);凭据全部交给
|
||||||
|
// `is_sensitive_config_key` 统一模式匹配(与 Claude 提取器一致)。
|
||||||
|
// 只列固定名单会漏掉下一个 `*_API_KEY` —— 例如 `GOOGLE_API_KEY`
|
||||||
|
// (provider.rs 认可的一等 Gemini 凭据),而共享片段会被 deep-merge
|
||||||
|
// 回其它 Gemini 供应商,漏剥即等于把 A 账号的密钥写进 B 供应商并
|
||||||
|
// 发往 B 的 base_url。`GEMINI_API_KEY` 不必单列:`_KEY` 后缀已覆盖。
|
||||||
|
if key == "GOOGLE_GEMINI_BASE_URL" || Self::is_sensitive_config_key(key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Value::String(v) = value else {
|
let Value::String(v) = value else {
|
||||||
@@ -3263,6 +3751,221 @@ impl ProviderService {
|
|||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一次性清理:把历史泄漏进 Gemini 共享片段的凭据从所有存储位置抹掉。
|
||||||
|
///
|
||||||
|
/// 背景:`extract_gemini_common_config` 曾只剥离两个固定键名,`GOOGLE_API_KEY`
|
||||||
|
/// 等一等凭据会进入共享片段,再被 `apply_common_config_to_settings` 深合并进
|
||||||
|
/// **其它** Gemini 供应商的 env,随请求发往对方的 base_url。
|
||||||
|
///
|
||||||
|
/// 光修提取器不够:Gemini 的片段一旦生成就**永不自动重提取**(启动期
|
||||||
|
/// auto-extract 与导入后补提取都要求 `snippet.is_none()`,切换时的回写又只对
|
||||||
|
/// Claude / Codex 生效),所以存量片段会一直带着密钥继续注入。
|
||||||
|
///
|
||||||
|
/// 两个关键约束:
|
||||||
|
///
|
||||||
|
/// 1. **不能只清片段**。合并与剥离是一对靠「值相等」严格抵消的操作:切走供应商时
|
||||||
|
/// `remove_common_config_from_settings` 依据片段内容把注入的键删掉。片段里一旦
|
||||||
|
/// 没了这个键,backfill 就会把 live 中残留的密钥原样写进受害供应商的
|
||||||
|
/// `settings_config`——泄漏从瞬时污染变成永久污染。所以片段、各供应商配置、
|
||||||
|
/// live 文件必须一起清。
|
||||||
|
/// 2. **按值相等定向删除,不按键名一刀切**。复用 `remove_common_config_from_settings`
|
||||||
|
/// 可以只清掉扩散出去的那一份,保留某个供应商自己写的、值不同的同名键。
|
||||||
|
///
|
||||||
|
/// 步骤顺序本身是安全属性的一部分:**清片段必须排在最后**。片段是
|
||||||
|
/// `remove_common_config_from_settings` 唯一的"该剥哪些键"来源,一旦清空,任何
|
||||||
|
/// 残留(live 文件里的、下一轮重试要处理的)都再也无法被识别和剥离。所以所有
|
||||||
|
/// 可能失败的步骤都排在它前面,失败即带错返回,让下次启动能原样重来。
|
||||||
|
///
|
||||||
|
/// 清理后部分供应商会显示缺少 API Key,需用户重填——这是正确行为:那把密钥本就
|
||||||
|
/// 不属于它们。(受害者原有的同名键在合并时已被覆盖,无法恢复。)动手前会往
|
||||||
|
/// settings 的 `gemini_common_config_scrub_audit_v1` 写一条审计记录,内容是
|
||||||
|
/// **键名与受影响的供应商 id,不含值**:`settings` 会随 WebDAV/S3 同步上传,
|
||||||
|
/// 而这里处理的正是必须销毁的凭据,留值等于把一次清除换成一份跨设备扩散、
|
||||||
|
/// 没有界面入口、永不过期的明文副本。
|
||||||
|
pub async fn scrub_leaked_gemini_common_config(state: &AppState) -> Result<(), AppError> {
|
||||||
|
const FLAG: &str = "gemini_common_config_credentials_scrubbed_v1";
|
||||||
|
const AUDIT_KEY: &str = "gemini_common_config_scrub_audit_v1";
|
||||||
|
let app = AppType::Gemini;
|
||||||
|
|
||||||
|
if state.db.get_bool_flag(FLAG).unwrap_or(false) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(snippet_text) = state.db.get_config_snippet(app.as_str())? else {
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 片段解析不了就不动它,只标记完成——乱改用户数据比留着更糟
|
||||||
|
let Ok(Value::Object(entries)) = serde_json::from_str::<Value>(&snippet_text) else {
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut poison = serde_json::Map::new();
|
||||||
|
let mut clean = serde_json::Map::new();
|
||||||
|
for (key, value) in entries {
|
||||||
|
if Self::is_sensitive_config_key(&key) {
|
||||||
|
poison.insert(key, value);
|
||||||
|
} else {
|
||||||
|
clean.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if poison.is_empty() {
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"检测到 {} 个凭据键残留在 Gemini 通用配置片段中,开始一次性清理",
|
||||||
|
poison.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let poison_keys: Vec<String> = poison.keys().cloned().collect();
|
||||||
|
let poison_value = Value::Object(poison);
|
||||||
|
let poison_text = serde_json::to_string(&poison_value)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
|
||||||
|
// 1) 先算出各供应商清理后的配置,但**先不落库**
|
||||||
|
let providers = state.db.get_all_providers(app.as_str())?;
|
||||||
|
let mut pending: Vec<(String, Provider, Value)> = Vec::new();
|
||||||
|
for (id, provider) in providers {
|
||||||
|
let cleaned = match live::remove_common_config_from_settings(
|
||||||
|
&app,
|
||||||
|
&provider.settings_config,
|
||||||
|
&poison_text,
|
||||||
|
) {
|
||||||
|
Ok(cleaned) => cleaned,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("清理供应商 '{id}' 的泄漏凭据失败: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if cleaned != provider.settings_config {
|
||||||
|
pending.push((id, provider, cleaned));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 落库前留一份审计记录:**只记键名与受影响的供应商,不记值**。
|
||||||
|
//
|
||||||
|
// 「按值相等定向删除」在一种合法场景下也会命中:用户有意在多个供应商里
|
||||||
|
// 复用同一把 key。所以必须留下"删了什么、从哪删的",否则用户只能靠翻
|
||||||
|
// 日志。但不能留值——`settings` 表不在 `SYNC_SKIP_TABLES` 里,会随
|
||||||
|
// WebDAV/S3 同步上传,而这里处理的恰恰是必须销毁的泄漏凭据:留值等于
|
||||||
|
// 把一次清除换成一份没有界面入口、永不过期、还会跨设备扩散的明文副本。
|
||||||
|
// 密钥本来就该轮换,可恢复性不值这个代价。
|
||||||
|
let removed_env_keys = |before: &Value, after: &Value| -> Vec<String> {
|
||||||
|
let before_env = before.get("env").and_then(Value::as_object);
|
||||||
|
let after_env = after.get("env").and_then(Value::as_object);
|
||||||
|
match (before_env, after_env) {
|
||||||
|
(Some(before_env), Some(after_env)) => before_env
|
||||||
|
.keys()
|
||||||
|
.filter(|key| !after_env.contains_key(*key))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
(Some(before_env), None) => before_env.keys().cloned().collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let audit = serde_json::json!({
|
||||||
|
"removedFromSnippet": poison_keys,
|
||||||
|
"providers": pending
|
||||||
|
.iter()
|
||||||
|
.map(|(id, provider, cleaned)| serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"removedKeys": removed_env_keys(&provider.settings_config, cleaned),
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
let audit_text = serde_json::to_string(&audit)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
// 只在没有记录时写。provider 的写入不是一个事务(每次 save_provider 各自
|
||||||
|
// 提交),上一轮可能改到一半就中止;此时完成标记没置位,下次启动会重跑,
|
||||||
|
// 而重跑看到的"原始状态"已经残缺。无条件 INSERT OR REPLACE 会拿这份残缺
|
||||||
|
// 记录盖掉第一轮那份完整的。
|
||||||
|
if state.db.get_setting(AUDIT_KEY)?.is_none() {
|
||||||
|
state.db.set_setting(AUDIT_KEY, &audit_text)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 各供应商 settings_config:按值相等定向删除扩散出去的副本
|
||||||
|
for (id, provider, cleaned) in pending {
|
||||||
|
let mut updated = provider;
|
||||||
|
updated.settings_config = cleaned;
|
||||||
|
state.db.save_provider(app.as_str(), &updated)?;
|
||||||
|
log::info!("已从 Gemini 供应商 '{id}' 中清除泄漏的共享凭据");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 代理接管中的 live 快照里也可能有一份副本。这一步的失败**必须传播**:
|
||||||
|
//
|
||||||
|
// 关代理时 `restore_live_config_for_app_with_fallback_inner`(proxy.rs:869)
|
||||||
|
// 会把这份快照原样写回 `~/.gemini/.env`。若它仍带毒而我们照样清了片段、置了
|
||||||
|
// 完成标记,那么代理一停凭据就当场复活,而一次性标记又保证不会再清第二次;
|
||||||
|
// 此后片段里已没有这个键,下一次切换的 backfill 就把它永久写进受害供应商的
|
||||||
|
// 配置——还是本函数开头那个顺序陷阱,只是换了扇门进来。
|
||||||
|
//
|
||||||
|
// 带错返回是安全的失败方式:调用方(lib.rs:1189)只记 warn 不中断启动,
|
||||||
|
// 片段和标记都原样留着,下次启动照原样重来。
|
||||||
|
if let Some(backup) = state.db.get_live_backup(app.as_str()).await? {
|
||||||
|
let original: Value = serde_json::from_str(&backup.original_config)
|
||||||
|
.map_err(|e| AppError::Message(format!("解析 Gemini 代理接管备份失败: {e}")))?;
|
||||||
|
let cleaned = live::remove_common_config_from_settings(&app, &original, &poison_text)?;
|
||||||
|
if cleaned != original {
|
||||||
|
let text = serde_json::to_string(&cleaned)
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
state.db.save_live_backup(app.as_str(), &text).await?;
|
||||||
|
log::info!("已从 Gemini 代理接管备份中清除泄漏的共享凭据");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) `~/.gemini/.env`:**定向**删除,且必须在清片段之前做,失败即中止。
|
||||||
|
//
|
||||||
|
// 为什么不用 `sync_current_provider_for_app` 重投影:它在没有当前供应商
|
||||||
|
// 时直接返回 Ok 而根本不写文件,泄漏值会原样留在 live 里;等片段被清空
|
||||||
|
// 之后,下次切换时 `remove_common_config_from_settings` 再也认不出这个
|
||||||
|
// 键,backfill 就把它永久写进受害供应商的配置——正是本函数开头说的那个
|
||||||
|
// 顺序陷阱,只是由"没修"变成"修了一半更糟"。定向删除还顺带保住了只存在
|
||||||
|
// 于 live、与供应商无关的手工 env(重投影会把它们抹掉)。
|
||||||
|
//
|
||||||
|
// 删除走 `remove_gemini_env_entries` 的**保序**实现而不是 read→HashMap→
|
||||||
|
// write 往返:后者会顺手抹掉注释、空行和无法识别的行,并按键名重排整个
|
||||||
|
// 文件。全量投影时那无所谓,但这里是一次用户没主动触发的启动期清理,不该
|
||||||
|
// 连带改写与泄漏无关的内容。
|
||||||
|
//
|
||||||
|
// 失败就带着错误返回:片段此刻还留着毒键,完成标记也没置位,下次启动能
|
||||||
|
// 照原样重来。清片段是不可逆的一步,必须排在所有会失败的步骤之后。
|
||||||
|
let poison_env: HashMap<String, String> = poison_value
|
||||||
|
.as_object()
|
||||||
|
.map(|map| {
|
||||||
|
map.iter()
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
value.as_str().map(|text| (key.clone(), text.to_string()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if crate::gemini_config::remove_gemini_env_entries(&poison_env)? {
|
||||||
|
log::info!("已从 ~/.gemini/.env 中清除泄漏的共享凭据");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 片段本身:保留可共享的部分。全部清空时删行而不是写 "{}"——留着空行会让
|
||||||
|
// should_auto_extract_config_snippet 永远为 false,用户的合法共享配置再也
|
||||||
|
// 重建不回来。同理绝不置 cleared 标记。
|
||||||
|
if clean.is_empty() {
|
||||||
|
state.db.set_config_snippet(app.as_str(), None)?;
|
||||||
|
} else {
|
||||||
|
let cleaned_snippet = serde_json::to_string_pretty(&Value::Object(clean))
|
||||||
|
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_config_snippet(app.as_str(), Some(cleaned_snippet))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.db.set_setting(FLAG, "true")?;
|
||||||
|
log::info!("Gemini 通用配置凭据清理完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract common config for OpenCode (JSON format)
|
/// Extract common config for OpenCode (JSON format)
|
||||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||||
// OpenCode uses a different config structure with npm, options, models
|
// OpenCode uses a different config structure with npm, options, models
|
||||||
|
|||||||
+1432
-120
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,77 @@ import { configApi } from "@/lib/api";
|
|||||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
||||||
|
|
||||||
|
/** 供应商专属、不可共享的键(端点与主凭据),按名单剥离。 */
|
||||||
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||||
"GOOGLE_GEMINI_BASE_URL",
|
"GOOGLE_GEMINI_BASE_URL",
|
||||||
"GEMINI_API_KEY",
|
"GEMINI_API_KEY",
|
||||||
] as const;
|
] as const;
|
||||||
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
|
||||||
|
/**
|
||||||
|
* 凭据键的模式匹配,对应后端 `ProviderService::is_sensitive_config_key`。
|
||||||
|
*
|
||||||
|
* 共享片段会被深合并进**其它** Gemini 供应商的 env,所以任何凭据都不能进来。
|
||||||
|
* 固定名单挡不住下一个 `*_API_KEY`(`GOOGLE_API_KEY` 就是这么漏过去的),
|
||||||
|
* 必须用模式覆盖整类。两端判定要一致,否则后端清理完还能从这里手工写回去。
|
||||||
|
*/
|
||||||
|
const SENSITIVE_EXACT = [
|
||||||
|
"APIKEY",
|
||||||
|
"API_KEY",
|
||||||
|
"TOKEN",
|
||||||
|
"SECRET",
|
||||||
|
"PASSWORD",
|
||||||
|
"CREDENTIALS",
|
||||||
|
];
|
||||||
|
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但不误伤复数 `_TOKENS`(那是正常配置)
|
||||||
|
const SENSITIVE_SUFFIXES = [
|
||||||
|
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),必须单列:
|
||||||
|
// 只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通的那一种漏在外面。
|
||||||
|
"_KEY",
|
||||||
|
"_API_KEY",
|
||||||
|
"_ACCESS_KEY",
|
||||||
|
"_ACCESS_KEY_ID",
|
||||||
|
"_KEY_ID",
|
||||||
|
"_PRIVATE_KEY",
|
||||||
|
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`。
|
||||||
|
"_APIKEY",
|
||||||
|
"_ACCESSKEY",
|
||||||
|
"_SECRETKEY",
|
||||||
|
"_APITOKEN",
|
||||||
|
"_AUTH_TOKEN",
|
||||||
|
"_TOKEN",
|
||||||
|
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
|
||||||
|
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
|
||||||
|
"_PAT",
|
||||||
|
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`,`_PWD` 不会误伤 PWD / OLDPWD。
|
||||||
|
"_PWD",
|
||||||
|
"_PASS",
|
||||||
|
"_PASSPHRASE",
|
||||||
|
"_CREDS",
|
||||||
|
];
|
||||||
|
const SENSITIVE_CONTAINS = [
|
||||||
|
"SECRET",
|
||||||
|
"PASSWORD",
|
||||||
|
"PASSWD",
|
||||||
|
"CREDENTIAL",
|
||||||
|
"PRIVATE_KEY",
|
||||||
|
"BEARER_TOKEN",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isForbiddenCommonEnvKey(name: string): boolean {
|
||||||
|
if (
|
||||||
|
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
|
||||||
|
name as (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const upper = name.toUpperCase();
|
||||||
|
return (
|
||||||
|
SENSITIVE_EXACT.includes(upper) ||
|
||||||
|
SENSITIVE_SUFFIXES.some((suffix) => upper.endsWith(suffix)) ||
|
||||||
|
SENSITIVE_CONTAINS.some((part) => upper.includes(part))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface UseGeminiCommonConfigProps {
|
interface UseGeminiCommonConfigProps {
|
||||||
envValue: string;
|
envValue: string;
|
||||||
@@ -90,9 +156,7 @@ export function useGeminiCommonConfig({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(parsed);
|
const keys = Object.keys(parsed);
|
||||||
const forbiddenKeys = keys.filter((key) =>
|
const forbiddenKeys = keys.filter(isForbiddenCommonEnvKey);
|
||||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
|
||||||
);
|
|
||||||
if (forbiddenKeys.length > 0) {
|
if (forbiddenKeys.length > 0) {
|
||||||
return {
|
return {
|
||||||
env: {},
|
env: {},
|
||||||
|
|||||||
@@ -2283,6 +2283,9 @@
|
|||||||
"skillDirNotFound": "Skill directory not found: {{path}}",
|
"skillDirNotFound": "Skill directory not found: {{path}}",
|
||||||
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
|
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
|
||||||
"emptyArchive": "Downloaded archive is empty",
|
"emptyArchive": "Downloaded archive is empty",
|
||||||
|
"invalidRepoRef": "Invalid repository reference: {{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "Archive exceeds the {{limit_mb}} MB extraction limit; aborted",
|
||||||
|
"archiveTooManyEntries": "Archive has too many entries ({{count}}); the limit is {{limit}}",
|
||||||
"downloadFailed": "Download failed: HTTP {{status}}",
|
"downloadFailed": "Download failed: HTTP {{status}}",
|
||||||
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
||||||
"httpError": "HTTP error {{status}}",
|
"httpError": "HTTP error {{status}}",
|
||||||
|
|||||||
@@ -2283,6 +2283,9 @@
|
|||||||
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
||||||
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
|
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
|
||||||
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
||||||
|
"invalidRepoRef": "リポジトリの指定が不正です:{{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "アーカイブが展開上限 {{limit_mb}} MB を超えたため中止しました",
|
||||||
|
"archiveTooManyEntries": "アーカイブのエントリ数が多すぎます({{count}})。上限は {{limit}} です",
|
||||||
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
||||||
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
||||||
"httpError": "HTTP エラー {{status}}",
|
"httpError": "HTTP エラー {{status}}",
|
||||||
|
|||||||
@@ -2254,6 +2254,9 @@
|
|||||||
"skillDirNotFound": "技能目錄不存在:{{path}}",
|
"skillDirNotFound": "技能目錄不存在:{{path}}",
|
||||||
"directoryConflict": "技能目錄 '{{directory}}' 已被 {{existing_repo}} 佔用,無法從 {{new_repo}} 安裝",
|
"directoryConflict": "技能目錄 '{{directory}}' 已被 {{existing_repo}} 佔用,無法從 {{new_repo}} 安裝",
|
||||||
"emptyArchive": "下載的壓縮檔為空",
|
"emptyArchive": "下載的壓縮檔為空",
|
||||||
|
"invalidRepoRef": "倉庫位址不合法:{{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "壓縮檔解壓後超過 {{limit_mb}} MB 上限,已中止",
|
||||||
|
"archiveTooManyEntries": "壓縮檔項目過多({{count}}),上限 {{limit}}",
|
||||||
"downloadFailed": "下載失敗:HTTP {{status}}",
|
"downloadFailed": "下載失敗:HTTP {{status}}",
|
||||||
"allBranchesFailed": "所有分支下載失敗,嘗試了:{{branches}}",
|
"allBranchesFailed": "所有分支下載失敗,嘗試了:{{branches}}",
|
||||||
"httpError": "HTTP 錯誤 {{status}}",
|
"httpError": "HTTP 錯誤 {{status}}",
|
||||||
|
|||||||
@@ -2283,6 +2283,9 @@
|
|||||||
"skillDirNotFound": "技能目录不存在:{{path}}",
|
"skillDirNotFound": "技能目录不存在:{{path}}",
|
||||||
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
|
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
|
||||||
"emptyArchive": "下载的压缩包为空",
|
"emptyArchive": "下载的压缩包为空",
|
||||||
|
"invalidRepoRef": "仓库地址不合法:{{owner}}/{{name}}",
|
||||||
|
"archiveTooLarge": "压缩包解压后超过 {{limit_mb}} MB 上限,已中止",
|
||||||
|
"archiveTooManyEntries": "压缩包条目过多({{count}}),上限 {{limit}}",
|
||||||
"downloadFailed": "下载失败:HTTP {{status}}",
|
"downloadFailed": "下载失败:HTTP {{status}}",
|
||||||
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
||||||
"httpError": "HTTP 错误 {{status}}",
|
"httpError": "HTTP 错误 {{status}}",
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function getErrorI18nKey(code: string): string {
|
|||||||
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
||||||
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
||||||
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
||||||
|
INVALID_REPO_REF: "skills.error.invalidRepoRef",
|
||||||
|
ARCHIVE_TOO_LARGE: "skills.error.archiveTooLarge",
|
||||||
|
ARCHIVE_TOO_MANY_ENTRIES: "skills.error.archiveTooManyEntries",
|
||||||
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
||||||
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
|
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user