Compare commits

...

4 Commits

Author SHA1 Message Date
makoMakoGo eb356e15bd fix(skills): resolve source dir by SKILL.md anchor instead of name (#4153)
* fix(skills): resolve source dir by SKILL.md anchor instead of name

resolve_skill_source_dir previously guessed the source dir via root.join(name).is_dir() without verifying SKILL.md, misjudging same-name non-skill dirs (e.g. the ast-grep plugin wrapper dir in ast-grep/agent-skill) and causing install failure #4141.

Now anchors on SKILL.md: direct + SKILL.md check -> root manifest explicit skills[] -> fallback by name -> root fallback. Adds 5 layout tests.

Closes #4141

* fix(skills): drop speculative manifest resolver path

resolve_via_manifest (parsing root .claude-plugin/marketplace.json &
plugin.json explicit skills[]) is inert for the actual #4141 case: the
real ast-grep/agent-skill marketplace.json declares no skills[] array,
so the manifest branch never produces a candidate. The #4141 fix is
delivered entirely by resolve_skill_source_dir step 1's SKILL.md anchor
plus the pre-existing find_skill_dir_by_name DFS.

Keeping the manifest path would pull npx-skills package-parity semantics
(pluginRoot / source / remote-object source / skills[] / "./"-validation
/ ...) into a bug hotfix, with no real manifest proving it is not dead
code. Drop it to keep this PR a focused #4141 hotfix.

- remove SkillMarketplaceMetadata / SkillManifestPlugin /
  SkillMarketplaceManifest, resolve_via_manifest, sanitize_manifest_path
- narrow resolve_skill_source_dir to 3 steps
  (direct+SKILL.md -> by-name DFS+SKILL.md -> root+SKILL.md -> None)
- replace the two synthetic manifest tests with a negative case:
  same-name wrapper dir without SKILL.md and no inner skill -> None

cargo test --lib resolve_skill_source_dir: 7 passed
cargo clippy --lib: clean
2026-08-03 19:05:51 +08:00
mhy1227 f38722a440 feat(pricing): seed Qwen3.8 Max built-in model pricing (#6053)
* feat(pricing): seed Qwen3.8 Max built-in model pricing

Add insert-if-absent row for qwen3.8-max at 2/6 USD per Mtok input/output with 0.20 cache read.

* fix(pricing): set qwen3.8-max cache write to 2.50

Align cache_write with official explicit context-cache rate (125 percent of input). cache_read stays 0.20 (10 percent hit).

* fix(pricing): correct qwen3.8-max cache read price

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-08-03 17:57:24 +08:00
Xu Lei 13ea497ab0 fix(proxy): improve GitHub Copilot compatibility with modern Claude Code (#5832)
* 修复 Copilot 与新版 Claude Code 的兼容问题

* docs(proxy): correct Copilot placeholder rationale to the real mechanism

Claude Code (verified on 2.1.220) does not format-validate ANTHROPIC_API_KEY
against sk-ant-*: in headless mode the placeholder is sent upstream as-is.
The actual failure mode is the interactive custom-API-key approval prompt,
which defaults to "No (recommended)" — following the default ignores the
key and lands users in "Not logged in". Also drop the #3289 citation,
which describes a missing-placeholder scenario, not key validation.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-08-03 10:26:22 +08:00
mao qinghui 8383076791 fix(hermes): use SOUL.md instead of AGENTS.md for Hermes prompt filename (#5779)
* fix(hermes): use SOUL.md instead of AGENTS.md for Hermes prompt filename

* test(hermes): add regression test for SOUL.md prompt filename

---------

Co-authored-by: mmm-05610 <maoqh@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-08-02 22:02:06 +08:00
7 changed files with 190 additions and 22 deletions
+1
View File
@@ -2237,6 +2237,7 @@ impl Database {
"0", "0",
), ),
// Qwen 系列 (阿里巴巴) // Qwen 系列 (阿里巴巴)
("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"),
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"), ("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"), ("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
( (
+17 -1
View File
@@ -33,13 +33,29 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => "CLAUDE.md", AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md", AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md", AppType::Gemini => "GEMINI.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md", AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw => "AGENTS.md",
AppType::Hermes => "SOUL.md",
AppType::ClaudeDesktop => unreachable!("handled above"), AppType::ClaudeDesktop => unreachable!("handled above"),
}; };
Ok(base_dir.join(filename)) Ok(base_dir.join(filename))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hermes_prompt_file_uses_soul_md() {
let path = prompt_file_path(&AppType::Hermes).expect("Hermes prompt path");
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("SOUL.md")
);
}
}
fn get_base_dir_with_fallback( fn get_base_dir_with_fallback(
primary_path: PathBuf, primary_path: PathBuf,
fallback_dir: &str, fallback_dir: &str,
+11
View File
@@ -87,6 +87,17 @@ impl Provider {
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex") || self.claude_base_url_contains("chatgpt.com/backend-api/codex")
} }
/// Whether the provider form's "auth field" was explicitly set to
/// ANTHROPIC_API_KEY. The form only persists `meta.apiKeyField` for the
/// non-default choice, so `None` means the default ANTHROPIC_AUTH_TOKEN.
pub fn claude_uses_api_key_field(&self) -> bool {
self.meta
.as_ref()
.and_then(|m| m.api_key_field.as_deref())
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
.unwrap_or(false)
}
fn provider_type(&self) -> Option<&str> { fn provider_type(&self) -> Option<&str> {
self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
} }
+9
View File
@@ -1183,6 +1183,15 @@ impl RequestForwarder {
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body); super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
self.apply_copilot_live_model_resolution(provider, &mut mapped_body) self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
.await; .await;
// Strip the [1M] context marker after Copilot normalization/resolve.
// A user's mapped value (e.g. "gpt-5.6-sol[1M]") carries [1M] as a
// Claude Code context-capability declaration that upstream APIs reject
// as part of the model name. The preceding normalization step already
// rewrites claude-xxx[1M] into the "-1m" dash form Copilot accepts, and
// the strip helper only touches the "[1m]" bracket form, so "-1m"
// variants pass through unchanged.
mapped_body =
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
} else if !codex_responses_to_anthropic { } else if !codex_responses_to_anthropic {
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the // Skip on the Codex→Anthropic path: stripping [1m] here would break both the
// model-catalog match (apply_codex_upstream_model) and the transform's own // model-catalog match (apply_codex_upstream_model) and the transform's own
+64 -10
View File
@@ -95,9 +95,16 @@ impl ProxyService {
let auth_policy = if provider.uses_managed_account_auth() { let auth_policy = if provider.uses_managed_account_auth() {
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留 // Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。 // ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
// Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。 // Copilot 默认同样注入 AUTH_TOKEN 占位符:Claude Code(实测 2.1.220
// 对 ANTHROPIC_API_KEY 会弹"是否使用该自定义 key"确认框且默认
// "No (recommended)",按默认走后占位符被忽略、落入 Not logged in
// (并非 sk-ant-* 格式校验——headless 下占位符原样出站);AUTH_TOKEN
// 作为网关 Bearer 被直接信任,零弹窗。仅当供应商表单显式选择了
// ANTHROPIC_API_KEYmeta.apiKeyField)时才保留 API_KEY 占位,以规避
// 与 /login 管理的 key 冲突(#1049)。
ClaudeTakeoverAuthPolicy::ManagedAccount { ClaudeTakeoverAuthPolicy::ManagedAccount {
keep_auth_token: !provider.is_github_copilot(), keep_auth_token: !provider.is_github_copilot()
|| !provider.claude_uses_api_key_field(),
} }
} else { } else {
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
@@ -197,7 +204,10 @@ impl ProxyService {
// - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。 // - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider // 无条件注入而非"已存在才保留":热切换路径传入的是 provider
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。 // settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
// - Copilot 仅 API_KEY:避免与 /login 管理的 key 冲突(#1049)。 // - Copilot 默认 AUTH_TOKENAPI_KEY 占位符会触发 Claude Code 的
// 自定义 key 确认框(默认 "No (recommended)"),按默认走即
// Not logged in;仅当表单显式选择了 ANTHROPIC_API_KEY 时才用
// API_KEY 占位以规避 /login key 冲突(#1049)。
if keep_auth_token { if keep_auth_token {
env.insert( env.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(), "ANTHROPIC_AUTH_TOKEN".to_string(),
@@ -3304,7 +3314,7 @@ mod tests {
} }
#[test] #[test]
fn managed_account_claude_takeover_uses_api_key_placeholder() { fn managed_account_claude_takeover_uses_auth_token_placeholder() {
let mut provider = Provider::with_id( let mut provider = Provider::with_id(
"copilot".to_string(), "copilot".to_string(),
"GitHub Copilot".to_string(), "GitHub Copilot".to_string(),
@@ -3333,13 +3343,13 @@ mod tests {
.and_then(|value| value.as_object()) .and_then(|value| value.as_object())
.expect("env should exist"); .expect("env should exist");
assert_eq!( assert_eq!(
env.get("ANTHROPIC_API_KEY") env.get("ANTHROPIC_AUTH_TOKEN")
.and_then(|value| value.as_str()), .and_then(|value| value.as_str()),
Some(PROXY_TOKEN_PLACEHOLDER) Some(PROXY_TOKEN_PLACEHOLDER)
); );
assert!( assert!(
env.get("ANTHROPIC_AUTH_TOKEN").is_none(), env.get("ANTHROPIC_API_KEY").is_none(),
"managed OAuth providers should avoid Claude Auth Token login semantics" "API_KEY placeholders trigger Claude Code's custom-key approval prompt (defaults to No), landing users in Not logged in"
); );
} }
@@ -3421,8 +3431,8 @@ mod tests {
"CLAUDE_CODE_SUBAGENT_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL",
Some("claude-sonnet-4.6[1M]"), Some("claude-sonnet-4.6[1M]"),
); );
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None); assert_env_str(env, "ANTHROPIC_API_KEY", None);
} }
#[test] #[test]
@@ -3675,7 +3685,7 @@ mod tests {
} }
#[test] #[test]
fn managed_account_claude_takeover_copilot_removes_stale_auth_token() { fn managed_account_claude_takeover_copilot_defaults_to_auth_token() {
let mut provider = Provider::with_id( let mut provider = Provider::with_id(
"copilot".to_string(), "copilot".to_string(),
"GitHub Copilot".to_string(), "GitHub Copilot".to_string(),
@@ -3691,6 +3701,48 @@ mod tests {
..Default::default() ..Default::default()
}); });
let mut live_config = json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com",
"ANTHROPIC_AUTH_TOKEN": "stale-token",
"ANTHROPIC_API_KEY": "stale-key"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
// Default Copilot takeover injects AUTH_TOKEN: the API_KEY placeholder
// triggers Claude Code's custom-key approval prompt (defaults to
// "No (recommended)"), which lands users in "Not logged in".
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_API_KEY", None);
}
#[test]
fn managed_account_claude_takeover_copilot_honors_api_key_field_choice() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
..Default::default()
});
let mut live_config = json!({ let mut live_config = json!({
"env": { "env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com", "ANTHROPIC_BASE_URL": "https://stale.example.com",
@@ -3707,6 +3759,8 @@ mod tests {
.get("env") .get("env")
.and_then(|value| value.as_object()) .and_then(|value| value.as_object())
.expect("env should exist"); .expect("env should exist");
// Explicit API-key-field choice keeps the API_KEY placeholder to avoid
// conflicting with the /login-managed key (#1049).
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
} }
+87 -10
View File
@@ -2374,31 +2374,38 @@ impl SkillService {
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。 /// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
/// ///
/// 兼容三种情况 /// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序
/// 1. `skills/foo` 这类直接相对路径 /// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录; /// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录 /// 3. 兜底:仓库根本身含 `SKILL.md`
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> { fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
let source_rel = Self::sanitize_skill_source_path(raw_directory)?; let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
let install_name = source_rel
.file_name()
.map(|n| n.to_string_lossy().to_string())?;
// 1. 直接相对路径命中(明确路径优先)——必须校验 SKILL.md,否则同名空壳目录
// (如 ast-grep/agent-skill 根下的 plugin 包目录 ast-grep/)会被误判为源目录。
let direct = root.join(&source_rel); let direct = root.join(&source_rel);
if direct.is_dir() { if direct.is_dir() && direct.join("SKILL.md").is_file() {
return Some(direct); return Some(direct);
} }
let target_name = source_rel.file_name()?.to_string_lossy().to_string(); // 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) { if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
log::info!( log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}", "Skill directory '{}' not found at direct path, using fallback: {}",
target_name, install_name,
found.display() found.display()
); );
return Some(found); return Some(found);
} }
if root.is_dir() && root.join("SKILL.md").exists() { // 3. 兜底:仓库根本身是 skill
if root.join("SKILL.md").is_file() {
log::info!( log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root", "Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
target_name, install_name,
); );
return Some(root.to_path_buf()); return Some(root.to_path_buf());
} }
@@ -4452,4 +4459,74 @@ mod tests {
"existing destination skill should be preserved" "existing destination skill should be preserved"
); );
} }
#[test]
fn resolve_skill_source_dir_rejects_same_name_wrapper_without_skill_md() {
// 复刻 issue #4141ast-grep/agent-skill 结构。仓库根下有同名目录 ast-grep/
// plugin 包,无 SKILL.md),真正的 skill 在 ast-grep/skills/ast-grep/SKILL.md。
let temp = tempdir().expect("tempdir");
let wrapper = temp.path().join("ast-grep");
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
fs::write(
wrapper.join(".claude-plugin").join("plugin.json"),
"{\"name\":\"ast-grep\"}",
)
.expect("write plugin.json");
let real_skill = wrapper.join("skills").join("ast-grep");
write_skill(&real_skill, "ast-grep");
// directory 只给了 skill 名 "ast-grep"skills.sh API 的语义),不能命中空壳 wrapper。
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep")
.expect("should resolve to the inner skill dir, not the same-name wrapper");
assert_eq!(resolved, real_skill);
assert!(resolved.join("SKILL.md").is_file());
}
#[test]
fn resolve_skill_source_dir_finds_two_level_catalog_skill() {
// catalog layoutskills/category/foo/SKILL.mddepth 3find_skill_dir_by_name 可达)。
let temp = tempdir().expect("tempdir");
let catalog_skill = temp.path().join("skills").join("category").join("foo");
write_skill(&catalog_skill, "Foo Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo")
.expect("should resolve the two-level catalog skill by name");
assert_eq!(resolved, catalog_skill);
}
#[test]
fn resolve_skill_source_dir_returns_none_for_wrapper_without_inner_skill() {
// 同名 wrapper 存在、无 SKILL.md,且无 inner skill / root SKILL.md 可兜底时,
// 必须返回 None——守住 #4141 这个 bug class 的负例(不能把空壳目录当源目录)。
let temp = tempdir().expect("tempdir");
let wrapper = temp.path().join("ast-grep");
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
fs::write(
wrapper.join(".claude-plugin").join("plugin.json"),
"{\"name\":\"ast-grep\"}",
)
.expect("write plugin.json");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep");
assert!(
resolved.is_none(),
"wrapper dir without SKILL.md and no inner skill must resolve to None, got {:?}",
resolved
);
}
#[test]
fn resolve_skill_source_dir_returns_none_when_no_skill_md_anywhere() {
let temp = tempdir().expect("tempdir");
fs::create_dir_all(temp.path().join("skills").join("foo")).expect("create empty skill dir");
fs::write(temp.path().join("README.md"), "no skills here").expect("write README");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo");
assert!(
resolved.is_none(),
"no SKILL.md anywhere must resolve to None"
);
}
} }
+1 -1
View File
@@ -32,7 +32,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
grokbuild: "AGENTS.md", grokbuild: "AGENTS.md",
opencode: "AGENTS.md", opencode: "AGENTS.md",
openclaw: "AGENTS.md", openclaw: "AGENTS.md",
hermes: "AGENTS.md", hermes: "SOUL.md",
}; };
const filename = filenameMap[appId]; const filename = filenameMap[appId];
const [name, setName] = useState(""); const [name, setName] = useState("");