完成 DirectProject 扩展导入阶段二

接入客户端已启用 Skill 的隔离运行时副本

将重命名同步到运行时 frontmatter 并保留原始来源

把客户端 Skill 集合纳入 app-server 连接池指纹

补充 Skill 运行时复制和指纹定向测试

更新 DirectProject 扩展阶段文档状态
This commit is contained in:
2026-08-31 11:58:06 +00:00
parent 92c56cb76f
commit 25ccd582ec
3 changed files with 233 additions and 6 deletions
@@ -570,7 +570,7 @@ struct CodexAppServerInner {
workspace_mode: CodexAppServerWorkspaceMode,
_provider_proxy: Option<CodexProviderProxy>,
tool_bridge: Option<DirectToolBridge>,
_skill_root: Option<std::path::PathBuf>,
_skill_roots: Option<Vec<std::path::PathBuf>>,
}
#[derive(Clone)]
@@ -615,6 +615,12 @@ fn game_creator_codex_app_server_pool_key(
} else {
"disabled".to_string()
};
let client_skill_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
crate::client_extensions::enabled_client_skill_fingerprint()
.unwrap_or_else(|_| "invalid-client-skills".to_string())
} else {
"disabled".to_string()
};
let stable = serde_json::json!({
"credentialFingerprint": credential_fingerprint,
"baseUrl": llm.base_url,
@@ -628,6 +634,7 @@ fn game_creator_codex_app_server_pool_key(
},
"workspaceMode": workspace_mode.pool_identity(),
"skillPackIdentity": skill_pack_identity,
"clientSkillIdentity": client_skill_identity,
"controlledWebSearch": llm.web_search_enabled,
"directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" },
"providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" },
@@ -1513,10 +1520,18 @@ impl CodexAppServerConnection {
))
})?;
}
let skill_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let skill_roots = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
install_agc_skill_pack(&isolated_os_home)
.map_err(platform_llm::LlmError::InvalidConfig)?;
Some(isolated_os_home.join(".agents").join("skills"))
let bundled_root = isolated_os_home.join(".agents").join("skills");
let mut roots = vec![bundled_root];
if let Some(client_root) =
crate::client_extensions::prepare_enabled_client_skill_root(&isolated_os_home)
.map_err(platform_llm::LlmError::InvalidConfig)?
{
roots.push(client_root);
}
Some(roots)
} else {
None
};
@@ -1632,7 +1647,7 @@ impl CodexAppServerConnection {
workspace_mode,
_provider_proxy: provider_proxy,
tool_bridge,
_skill_root: skill_root,
_skill_roots: skill_roots,
});
tokio::spawn(read_game_creator_codex_app_server_stdout(
Arc::downgrade(&inner),
@@ -1664,11 +1679,11 @@ impl CodexAppServerConnection {
if let Some(reason) = remote_control_disable_reason {
eprintln!("agent.codex_app_server.remote_control disabled reason={reason}");
}
if let Some(skill_root) = connection.inner._skill_root.as_ref() {
if let Some(skill_roots) = connection.inner._skill_roots.as_ref() {
connection
.request(
"skills/extraRoots/set",
serde_json::json!({ "extraRoots": [skill_root] }),
serde_json::json!({ "extraRoots": skill_roots }),
)
.await
.map_err(platform_llm::LlmError::Transport)?;
@@ -487,6 +487,165 @@ fn stored_item_view(
}
}
fn client_skill_set_fingerprint(index: &ClientExtensionIndex) -> String {
let mut entries = index
.items
.iter()
.filter(|item| item.extension_type == "skill" && item.enabled)
.map(|item| {
(
item.name.clone(),
item.source_relative_path.clone(),
item.fingerprint.clone(),
)
})
.collect::<Vec<_>>();
entries.sort();
let mut digest = Sha256::new();
digest.update(b"direct-project-client-skills.v1\0");
for (name, source_relative_path, fingerprint) in entries {
for value in [name, source_relative_path, fingerprint] {
digest.update((value.len() as u64).to_le_bytes());
digest.update(value.as_bytes());
}
}
format!("{:x}", digest.finalize())
}
pub(crate) fn enabled_client_skill_fingerprint() -> Result<String, String> {
let root = extensions_root()?;
let index = read_index(&root)?;
Ok(client_skill_set_fingerprint(&index))
}
fn rewrite_skill_name(content: &[u8], name: &str) -> Vec<u8> {
let Ok(text) = std::str::from_utf8(content) else {
return content.to_vec();
};
let mut lines = text.split_inclusive('\n');
let Some(first) = lines.next() else {
return content.to_vec();
};
if first.trim_matches(['\r', '\n', ' ', '\t']) != "---" {
return content.to_vec();
}
let newline = if first.ends_with("\r\n") {
"\r\n"
} else if first.ends_with('\n') {
"\n"
} else {
"\n"
};
let mut output = String::with_capacity(text.len() + name.len() + 16);
output.push_str(first);
let mut found_name = false;
let mut in_frontmatter = true;
for line in lines {
let content_end = line.trim_end_matches(['\r', '\n']);
let line_ending = &line[content_end.len()..];
if in_frontmatter && content_end.trim() == "---" {
if !found_name {
output.push_str("name: ");
output.push_str(name);
output.push_str(newline);
}
in_frontmatter = false;
output.push_str(line);
continue;
}
if in_frontmatter {
let trimmed = content_end.trim_start();
if trimmed.starts_with("name:") {
let indent_len = content_end.len() - trimmed.len();
output.push_str(&content_end[..indent_len]);
output.push_str("name: ");
output.push_str(name);
output.push_str(line_ending);
found_name = true;
continue;
}
}
output.push_str(line);
}
if in_frontmatter && !found_name {
output.push_str("name: ");
output.push_str(name);
output.push_str(newline);
}
output.into_bytes()
}
fn copy_skill_runtime_tree(
source_directory: &Path,
skill_file: &Path,
destination: &Path,
name: &str,
) -> Result<(), String> {
let files = sorted_directory_files(source_directory)?;
for (relative, path) in files {
let target = destination.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR));
if path == skill_file {
let content = fs::read(&path)
.map_err(|error| format!("读取客户端 Skill 失败:{}: {error}", path.display()))?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建运行时 Skill 目录失败:{}: {error}", parent.display())
})?;
}
fs::write(&target, rewrite_skill_name(&content, name))
.map_err(|error| format!("写入运行时 Skill 失败:{}: {error}", target.display()))?;
} else {
copy_file(&path, &target)?;
}
}
Ok(())
}
pub(crate) fn prepare_enabled_client_skill_root(
isolated_os_home: &Path,
) -> Result<Option<PathBuf>, String> {
let root = extensions_root()?;
let index = read_index(&root)?;
let client_skill_root = isolated_os_home.join(".agents").join("client-skills");
let mut copied_any = false;
for item in index
.items
.iter()
.filter(|item| item.extension_type == "skill" && item.enabled)
{
let Some(source) = index
.sources
.iter()
.find(|source| source.id == item.source_id)
else {
continue;
};
let source_root = root.join(&source.storage_path);
let source_relative_path = Path::new(&item.source_relative_path);
let skill_file = source_root.join(source_relative_path);
if !skill_file.is_file() {
continue;
}
let source_directory = skill_file.parent().unwrap_or(&source_root);
let destination = client_skill_root.join(&item.name);
if !copied_any {
fs::create_dir_all(&client_skill_root).map_err(|error| {
format!(
"创建客户端 Skill 运行时目录失败:{}: {error}",
client_skill_root.display()
)
})?;
copied_any = true;
}
copy_skill_runtime_tree(source_directory, &skill_file, &destination, &item.name)?;
}
Ok(copied_any.then_some(client_skill_root))
}
#[tauri::command]
pub(crate) async fn pick_client_extension_file(
app: tauri::AppHandle,
@@ -824,6 +983,57 @@ mod tests {
assert_eq!(parse_skill_name(&path), "skill");
}
#[test]
fn runtime_skill_copy_rewrites_only_frontmatter_name() {
let directory = tempfile::tempdir().expect("temp directory");
let source = directory.path().join("source");
let destination = directory.path().join("destination");
fs::create_dir_all(source.join("references")).expect("create source tree");
let skill_file = source.join("SKILL.md");
fs::write(
&skill_file,
"---\nname: original-skill\ndescription: keep this\n---\n\n# original-skill\n",
)
.expect("write source skill");
fs::write(source.join("references/guide.md"), "reference\n").expect("write reference");
copy_skill_runtime_tree(&source, &skill_file, &destination, "renamed-skill")
.expect("copy runtime skill");
assert_eq!(
fs::read_to_string(destination.join("SKILL.md")).expect("read runtime skill"),
"---\nname: renamed-skill\ndescription: keep this\n---\n\n# original-skill\n"
);
assert_eq!(
fs::read_to_string(destination.join("references/guide.md")).expect("read reference"),
"reference\n"
);
}
#[test]
fn client_skill_fingerprint_tracks_enabled_names_and_content() {
let mut index = ClientExtensionIndex::default();
index.items.push(StoredExtensionItem {
id: "skill-1".to_string(),
source_id: "source-1".to_string(),
extension_type: "skill".to_string(),
name: "first-skill".to_string(),
original_name: "first-skill".to_string(),
source_relative_path: "first/SKILL.md".to_string(),
enabled: true,
fingerprint: "content-a".to_string(),
last_error: None,
mcp_config: None,
});
let first = client_skill_set_fingerprint(&index);
index.items[0].enabled = false;
let disabled = client_skill_set_fingerprint(&index);
assert_ne!(first, disabled);
index.items[0].enabled = true;
index.items[0].name = "renamed-skill".to_string();
let renamed = client_skill_set_fingerprint(&index);
assert_ne!(first, renamed);
}
#[test]
fn fixture_directory_splits_independent_skill_and_mcp_items() {
let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -413,6 +413,8 @@ remove_client_extension(id)
### 阶段 2Skill 运行时闭环
当前实施状态:已完成。DirectProject 启动准备会在隔离 HOME 中分别创建内置 AGC Skill root 和客户端 Skill root,并通过同一次 `skills/extraRoots/set` 注册;客户端 Skill root 只包含当前 `enabled=true` 的 Skill 独立副本。用户重命名时仅在该临时副本的标准 frontmatter 中更新 `name`,原始来源副本和客户端索引保持不变。已启用 Skill 集合的名称、来源相对路径和内容指纹已纳入 DirectProject app-server pool key,启停或重命名从下一次启动生效,不热更新现有连接。
完成:
- 读取客户端已启用 Skill