From 1256468dc042f7a405079952cc8c0ac6a4d3e2dc Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:46:25 +0800 Subject: [PATCH 01/23] docs: design per-agent skill switches --- .../plans/2026-09-07-agent-skill-switches.md | 276 ++++++++++++++++++ .../2026-09-07-agent-skill-switches-design.md | 138 +++++++++ 2 files changed, 414 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-07-agent-skill-switches.md create mode 100644 docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md diff --git a/docs/superpowers/plans/2026-09-07-agent-skill-switches.md b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md new file mode 100644 index 0000000000..c0e885115a --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md @@ -0,0 +1,276 @@ +# Per-Agent Skill Switches Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add real per-agent availability switches to Settings > Skills without letting a shared skill toggle silently affect another Codeg-managed agent. + +**Architecture:** Native skill roots remain authoritative. Disabled entries live in deterministic sibling vaults; shared entries are fanned out into agent-unique roots before the shared source is hidden. The existing list/read/save/delete surface is extended so disabled skills remain manageable, and one new command performs serialized toggles over both transports. + +**Tech Stack:** Rust 2021, Tauri 2, Axum, Next.js 16, React 19, TypeScript, next-intl, Vitest. + +## Global Constraints + +- The switch is per agent and per scope; turning a shared skill off for one agent must preserve availability for every other Codeg-managed agent that already sees it. +- A disabled skill must not remain in any native scan root used by the selected agent. +- Read-only CLI skills cannot be toggled. +- Existing user files and unrelated worktree changes must be preserved. +- Both Tauri desktop and Axum server transports must expose the same behavior. + +--- + +### Task 1: Backend Skill State And Private Toggle + +**Files:** +- Modify: `src-tauri/src/acp/types.rs` +- Modify: `src-tauri/src/commands/acp.rs` + +**Interfaces:** +- Produces: `AgentSkillItem { enabled: bool, can_toggle: bool, ... }` +- Produces: `acp_set_agent_skill_enabled(agent_type, scope, skill_id, workspace_path, enabled) -> Result` + +- [ ] **Step 1: Write failing filesystem tests** + +Add tests that create directory and flat-file skills in temporary active roots, +then exercise the wished-for helpers: + +```rust +let disabled = disabled_skill_root(&skills); +set_skill_enabled_in_roots(&peers, AgentType::Codex, AgentSkillScope::Global, "demo", false)?; +assert!(!skills.join("demo").exists()); +assert!(disabled.join("demo").join("SKILL.md").is_file()); +let listed = list_skills_from_roots(AgentSkillScope::Global, &[skills], kind)?; +assert!(!listed[0].enabled); +``` + +- [ ] **Step 2: Run the focused Rust test and verify RED** + +Run: + +```bash +cd src-tauri && cargo test --features test-utils skill_enabled -- --nocapture +``` + +Expected: compilation fails because the new state fields and helpers do not +exist. + +- [ ] **Step 3: Implement disabled-root discovery and private moves** + +Add deterministic path and entry helpers, active-first list merging, lookup in +both active and disabled roots, a serialized mutation lock, collision +preflight, and idempotent private enable/disable moves. Extend read/save/delete +to resolve disabled entries. + +The command contract is: + +```rust +pub async fn acp_set_agent_skill_enabled( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + enabled: bool, +) -> Result; +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run the command from Step 2. Expected: private directory/flat-file, lookup, +idempotency, collision, and read-only tests pass. + +### Task 2: Shared Skill Fan-Out And Isolation + +**Files:** +- Modify: `src-tauri/src/commands/acp.rs` +- Reuse: `src-tauri/src/commands/experts.rs` + +**Interfaces:** +- Consumes: native roots from `skill_storage_spec` and `scoped_skill_dirs` +- Produces: an internal peer plan containing scan roots and one unique managed root per affected agent + +- [ ] **Step 1: Write failing shared-root tests** + +Use temporary roots for two peer agents and one shared root. Assert that +disabling for agent B moves the shared source to its vault, links agent A's +unique root to the canonical entry, leaves B's unique root empty, and reports A +enabled/B disabled. Add a peer-without-unique-root case that fails without any +move. + +```rust +assert!(peer_a.join("demo").join("SKILL.md").is_file()); +assert!(!peer_b.join("demo").exists()); +assert!(shared_disabled.join("demo").join("SKILL.md").is_file()); +``` + +- [ ] **Step 2: Run the focused Rust test and verify RED** + +Run: + +```bash +cd src-tauri && cargo test --features test-utils shared_skill -- --nocapture +``` + +Expected: the shared source disappears for both peers or the new planning API +is missing. + +- [ ] **Step 3: Implement shared preflight, fan-out, and rollback** + +Derive peers from `all_acp_agents()`, compare resolved scan-root paths, choose a +root used by exactly one peer, and create compatible directory/file links. Move +the shared source only after every destination has passed preflight. Track and +remove links plus restore the source if a later filesystem action fails. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run the command from Step 2. Expected: fan-out, isolation, refusal, and rollback +tests pass. + +### Task 3: Expose The Toggle Over Desktop And Server Transports + +**Files:** +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/src/web/handlers/acp.rs` +- Modify: `src-tauri/src/web/router.rs` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/tauri.ts` +- Modify: `src/lib/types.ts` + +**Interfaces:** +- Consumes: the backend command from Task 1 +- Produces: `acpSetAgentSkillEnabled(params): Promise` + +- [ ] **Step 1: Add the typed frontend call before registration** + +```ts +export async function acpSetAgentSkillEnabled(params: { + agentType: AgentType + scope: AgentSkillScope + skillId: string + workspacePath?: string | null + enabled: boolean +}): Promise +``` + +- [ ] **Step 2: Run TypeScript checking and verify RED** + +Run `pnpm exec tsc --noEmit`. Expected: the transport call or the new DTO fields +are unavailable until all mirrors are updated. + +- [ ] **Step 3: Register both transport paths** + +Add the Tauri invoke handler, the Axum request DTO/handler, and +`/acp_set_agent_skill_enabled`; mirror `enabled` and `can_toggle` in TypeScript +and send camelCase request keys through the shared transport. + +- [ ] **Step 4: Run TypeScript and Rust checks** + +Run: + +```bash +pnpm exec tsc --noEmit +cd src-tauri && cargo check +cd src-tauri && cargo check --no-default-features --bin codeg-server +``` + +Expected: all commands exit 0. + +### Task 4: Skills Settings Switch And Autocomplete Filtering + +**Files:** +- Create: `src/components/settings/skills-settings.test.tsx` +- Modify: `src/components/settings/skills-settings.tsx` +- Create: `src/hooks/use-agent-skills.test.tsx` +- Modify: `src/hooks/use-agent-skills.ts` + +**Interfaces:** +- Consumes: `AgentSkillItem.enabled`, `AgentSkillItem.can_toggle`, and `acpSetAgentSkillEnabled` +- Produces: a row switch whose accessible name identifies the skill and selected agent + +- [ ] **Step 1: Write failing component and hook tests** + +Mock the existing API module, render one enabled skill, click its switch, and +assert the exact request plus authoritative reload: + +```ts +expect(acpSetAgentSkillEnabled).toHaveBeenCalledWith({ + agentType: "codex", + scope: "global", + skillId: "demo", + workspacePath: null, + enabled: false, +}) +expect(acpListAgentSkills).toHaveBeenCalledTimes(3) +``` + +Also assert that `useAgentSkills` excludes `{ enabled: false }` and that +read-only/non-toggleable switches are disabled. + +- [ ] **Step 2: Run focused Vitest and verify RED** + +Run: + +```bash +pnpm test -- src/components/settings/skills-settings.test.tsx src/hooks/use-agent-skills.test.tsx +``` + +Expected: switch queries and disabled filtering fail because neither behavior +exists. + +- [ ] **Step 3: Implement the switch behavior** + +Add a stable switch to each list row, stop its click from changing row +selection, track one in-flight skill ID, call the new API, invalidate the cache, +reload the authoritative list, and show localized success/error feedback. Filter +disabled items in `useAgentSkills` before caching. + +- [ ] **Step 4: Run focused Vitest and verify GREEN** + +Run the command from Step 2. Expected: all focused tests pass. + +### Task 5: Localization And Final Verification + +**Files:** +- Modify: `src/i18n/messages/ar.json` +- Modify: `src/i18n/messages/de.json` +- Modify: `src/i18n/messages/en.json` +- Modify: `src/i18n/messages/es.json` +- Modify: `src/i18n/messages/fr.json` +- Modify: `src/i18n/messages/ja.json` +- Modify: `src/i18n/messages/ko.json` +- Modify: `src/i18n/messages/pt.json` +- Modify: `src/i18n/messages/zh-CN.json` +- Modify: `src/i18n/messages/zh-TW.json` + +**Interfaces:** +- Produces: matching keys for switch labels, enabled/disabled success, failure, and unavailable hints in every locale + +- [ ] **Step 1: Add the same message-key shape to all locales** + +Add `availability.enabled`, `availability.disabled`, +`availability.toggleAria`, `availability.readOnly`, +`availability.cannotIsolate`, and the corresponding toggle toast keys. + +- [ ] **Step 2: Run final frontend verification** + +```bash +pnpm eslint . +pnpm test +pnpm build +``` + +Expected: each command exits 0 with no failing tests. + +- [ ] **Step 3: Run final shared-backend verification** + +```bash +cd src-tauri && cargo test --features test-utils +cd src-tauri && cargo check --no-default-features --bin codeg-server +cd src-tauri && cargo test --no-default-features --bin codeg-server --lib +``` + +Expected: each command exits 0 with no failing tests. + +- [ ] **Step 4: Review the diff and commit** + +Run `git diff --check`, inspect `git diff --stat` and `git status --short`, then +commit only the task files on `task/1` without merging, rebasing, or pushing +`main`. diff --git a/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md new file mode 100644 index 0000000000..fe45c9414a --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md @@ -0,0 +1,138 @@ +# Per-Agent Skill Switches Design + +## Goal + +Add an availability switch to Settings > Skills so the same skill can be +enabled for one agent and disabled for another, matching the per-agent meaning +of MCP assignments. + +An off switch must change what the selected agent can discover. Hiding a skill +only from Codeg autocomplete is not sufficient. + +## Current Behavior + +Codeg discovers skills by scanning each agent's native global or project skill +directories. Some directories belong to one agent, such as +`~/.codex/skills`. Others, especially `.agents/skills`, are read by several +agents. The settings list currently exposes only discovered entries and has no +disabled state. + +## User Experience + +- Every skill row has an availability switch for the currently selected agent. +- An enabled skill remains discoverable by that agent. A disabled skill remains + visible in Settings so it can be previewed, edited, deleted, or re-enabled, + but it is omitted from Codeg skill autocomplete and from the agent's native + scan roots. +- A switch shows an in-progress state while the filesystem operation runs. +- On failure, the authoritative list is reloaded, the switch returns to its + prior state, and a localized error toast is shown. +- CLI-owned read-only skills remain visible but cannot be toggled. +- A skill whose shared installation cannot be separated without changing + another configured agent is marked non-toggleable instead of pretending the + operation succeeded. +- A new or reconnected agent session is required when an already-running agent + caches its skill inventory. + +## Storage Model + +No database flag is authoritative. The filesystem remains the source of truth +because the agent CLIs scan it directly. + +For each native skill root, Codeg uses a sibling vault that is outside the +agent's scan path: + +```text +~/.codex/skills/pdf/SKILL.md +~/.codex/.skills.codeg-disabled/pdf/SKILL.md +``` + +Directory skills and flat Markdown skills retain their original entry name and +layout in the vault. Renaming within the same parent filesystem makes a private +skill toggle reversible and preserves all supporting assets and symlink +identity. + +### Private Root + +Disabling moves the entry from the native root to its sibling vault. Enabling +moves it back. Destination collisions are rejected before mutation. + +### Shared Root + +Before hiding an entry from a shared root, Codeg identifies every configured +agent that currently relies on that root. For each peer other than the agent +being disabled, it creates a link in an agent-unique native root. The shared +entry is then moved into the shared root's sibling vault and becomes the +canonical link target. + +Example: + +```text +before: + ~/.agents/skills/pdf # Codex and Gemini can both see it + +after disabling only Gemini: + ~/.agents/.skills.codeg-disabled/pdf # canonical content, not scanned + ~/.codex/skills/pdf -> canonical # Codex still sees it + ~/.gemini/skills/pdf # absent, so Gemini does not see it +``` + +If a peer has no unique native skill root, or a conflicting entry blocks a +required link, Codeg rejects the operation before moving the shared source. +This preserves the per-agent contract for all agents managed by Codeg. Tools +outside Codeg that independently consume `.agents/skills` are outside this +assignment model. + +## API And Data Flow + +`AgentSkillItem` gains: + +```text +enabled: bool +can_toggle: bool +``` + +The list command scans active roots first and disabled vaults second. Active +entries win when the same ID occurs more than once. This means a peer link is +reported enabled even though its canonical source is held in a shared vault. + +A new command is available over both Tauri and Axum transports: + +```text +acp_set_agent_skill_enabled( + agent_type, + scope, + skill_id, + workspace_path, + enabled +) -> AgentSkillItem +``` + +Read, save, and delete operations resolve both active and disabled entries so +turning a skill off does not make it unmanageable. Saving a new skill creates +an enabled entry. The frontend invalidates its skill cache after every toggle, +and the generic `useAgentSkills` hook returns enabled entries only. + +## Consistency And Failure Handling + +- Skill mutations are serialized inside the backend. +- Validation and destination/link collision checks run before the first move. +- Shared fan-out records links created by the operation. If a later step fails, + those links are removed and a moved source is restored. +- Repeated enable or disable requests are idempotent. +- Symbolic links are moved as links, never followed and copied during private + disable operations. +- Built-in system paths retain the existing backend write protection. + +## Tests + +Rust tests cover private directory and flat-file toggles, disabled discovery, +idempotency, shared fan-out, peer isolation, collision refusal, rollback, and +read-only rejection. Existing skill storage tests continue to pin each agent's +native roots. + +Frontend tests cover switch state, the exact toggle request, cache invalidation, +authoritative reload, disabled autocomplete filtering, read-only/non-toggleable +rows, and failure rollback. The final gate runs focused tests followed by the +repository's frontend lint/test/build and Rust desktop/server checks appropriate +to the touched shared backend. From 42e65a214eac3cb31076b671cfe98977900882df Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:24:47 +0800 Subject: [PATCH 02/23] feat(skills): add enabled state discovery --- src-tauri/src/acp/types.rs | 5 + src-tauri/src/commands/acp.rs | 604 ++++++++++++++++++++++++++++++++-- 2 files changed, 586 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 50cdbf9934..ff55178303 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1510,6 +1510,11 @@ pub struct AgentSkillItem { pub scope: AgentSkillScope, pub layout: AgentSkillLayout, pub path: String, + /// Whether the skill currently lives in an agent-visible skills root. + pub enabled: bool, + /// Whether codeg may move the skill between its active root and disabled + /// vault. Built-in CLI skills are visible but cannot be toggled. + pub can_toggle: bool, /// Best-effort `description:` extracted from the SKILL.md YAML /// frontmatter. `None` when there is no frontmatter or no key. pub description: Option, diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 1ed2d91ca2..a81fa38991 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8343,6 +8343,7 @@ fn build_skill_item( scope: AgentSkillScope, layout: AgentSkillLayout, path: PathBuf, + enabled: bool, ) -> AgentSkillItem { let description = read_skill_description(&skill_content_path(layout, &path)); AgentSkillItem { @@ -8351,6 +8352,8 @@ fn build_skill_item( scope, layout, path: path.to_string_lossy().to_string(), + enabled, + can_toggle: true, description, read_only: false, } @@ -8377,6 +8380,13 @@ fn is_read_only_skill_path(agent_type: AgentType, skill_path: &Path) -> bool { skill_path.starts_with(&ro_root) } +fn apply_skill_capabilities(agent_type: AgentType, skill: &mut AgentSkillItem) { + if is_read_only_skill_path(agent_type, Path::new(&skill.path)) { + skill.read_only = true; + skill.can_toggle = false; + } +} + fn skill_content_path(layout: AgentSkillLayout, skill_path: &Path) -> PathBuf { match layout { AgentSkillLayout::SkillDirectory => skill_path.join("SKILL.md"), @@ -8438,6 +8448,15 @@ pub(crate) fn list_skills_from_dir( scope: AgentSkillScope, dir: &Path, kind: SkillStorageKind, +) -> Result, AcpError> { + list_skills_from_dir_with_state(scope, dir, kind, true) +} + +fn list_skills_from_dir_with_state( + scope: AgentSkillScope, + dir: &Path, + kind: SkillStorageKind, + enabled: bool, ) -> Result, AcpError> { if !dir.exists() { return Ok(Vec::new()); @@ -8469,7 +8488,13 @@ pub(crate) fn list_skills_from_dir( } by_id.insert( id.clone(), - build_skill_item(id, scope, AgentSkillLayout::SkillDirectory, path), + build_skill_item( + id, + scope, + AgentSkillLayout::SkillDirectory, + path, + enabled, + ), ); continue; } @@ -8488,7 +8513,7 @@ pub(crate) fn list_skills_from_dir( } by_id.insert( stem.clone(), - build_skill_item(stem, scope, AgentSkillLayout::MarkdownFile, path), + build_skill_item(stem, scope, AgentSkillLayout::MarkdownFile, path, enabled), ); } } @@ -8496,11 +8521,43 @@ pub(crate) fn list_skills_from_dir( Ok(by_id.into_values().collect()) } +pub(crate) fn disabled_skill_root(active_root: &Path) -> PathBuf { + active_root + .parent() + .unwrap_or_else(|| Path::new("")) + .join(".skills.codeg-disabled") +} + +pub(crate) fn list_skills_from_roots( + scope: AgentSkillScope, + roots: &[PathBuf], + kind: SkillStorageKind, +) -> Result, AcpError> { + let mut by_id = BTreeMap::new(); + + // Scan every active root before considering any vault so an active copy + // wins even when its root follows the vault-owning root in precedence. + for root in roots { + for skill in list_skills_from_dir_with_state(scope, root, kind, true)? { + by_id.entry(skill.id.clone()).or_insert(skill); + } + } + for root in roots { + let vault = disabled_skill_root(root); + for skill in list_skills_from_dir_with_state(scope, &vault, kind, false)? { + by_id.entry(skill.id.clone()).or_insert(skill); + } + } + + Ok(by_id.into_values().collect()) +} + fn locate_existing_skill( dir: &Path, kind: SkillStorageKind, skill_id: &str, scope: AgentSkillScope, + enabled: bool, ) -> Option { if matches!( kind, @@ -8513,6 +8570,7 @@ fn locate_existing_skill( scope, AgentSkillLayout::SkillDirectory, skill_dir, + enabled, )); } } @@ -8525,6 +8583,7 @@ fn locate_existing_skill( scope, AgentSkillLayout::MarkdownFile, file_path, + enabled, )); } } @@ -8539,13 +8598,46 @@ pub(crate) fn locate_existing_skill_across_dirs( scope: AgentSkillScope, ) -> Option { for dir in dirs { - if let Some(found) = locate_existing_skill(dir, kind, skill_id, scope) { + if let Some(found) = locate_existing_skill(dir, kind, skill_id, scope, true) { + return Some(found); + } + } + for dir in dirs { + if let Some(found) = + locate_existing_skill(&disabled_skill_root(dir), kind, skill_id, scope, false) + { return Some(found); } } None } +#[cfg(test)] +fn set_private_skill_enabled( + _root: &Path, + _kind: SkillStorageKind, + _scope: AgentSkillScope, + _skill_id: &str, + _enabled: bool, +) -> Result { + Err(AcpError::protocol( + "private skill moves are not implemented in task 1A", + )) +} + +#[cfg(test)] +async fn acp_set_agent_skill_enabled( + _agent_type: AgentType, + _scope: AgentSkillScope, + _skill_id: String, + _workspace_path: Option, + _enabled: bool, +) -> Result { + Err(AcpError::protocol( + "skill toggle command is not implemented in task 1A", + )) +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeConfig { @@ -12534,11 +12626,14 @@ pub async fn acp_list_agent_skills( path: dir.to_string_lossy().to_string(), exists: dir.exists(), }); - let listed = list_skills_from_dir(AgentSkillScope::Global, dir, spec.kind)?; - for skill in listed { - let key = format!("global:{}", skill.id); - skills_by_key.entry(key).or_insert(skill); - } + } + for skill in list_skills_from_roots( + AgentSkillScope::Global, + &spec.global_dirs, + spec.kind, + )? { + let key = format!("global:{}", skill.id); + skills_by_key.entry(key).or_insert(skill); } if let Some(workspace) = workspace_path.as_deref().map(str::trim) { @@ -12548,28 +12643,30 @@ pub async fn acp_list_agent_skills( // onto the workspace here instead would make a skill saved from a // nested workspace vanish from the list that is meant to show it. let base = project_skill_base(agent_type, workspace); - for relative in &spec.project_rel_dirs { - let project_dir = base.join(relative); + let project_dirs = spec + .project_rel_dirs + .iter() + .map(|relative| base.join(relative)) + .collect::>(); + for project_dir in &project_dirs { locations.push(AgentSkillLocation { scope: AgentSkillScope::Project, path: project_dir.to_string_lossy().to_string(), exists: project_dir.exists(), }); - let listed = - list_skills_from_dir(AgentSkillScope::Project, &project_dir, spec.kind)?; - for skill in listed { - let key = format!("project:{}", skill.id); - skills_by_key.entry(key).or_insert(skill); - } + } + for skill in + list_skills_from_roots(AgentSkillScope::Project, &project_dirs, spec.kind)? + { + let key = format!("project:{}", skill.id); + skills_by_key.entry(key).or_insert(skill); } } } let mut skills = skills_by_key.into_values().collect::>(); for skill in &mut skills { - if is_read_only_skill_path(agent_type, Path::new(&skill.path)) { - skill.read_only = true; - } + apply_skill_capabilities(agent_type, skill); } skills.sort_by(|a, b| { scope_rank(a.scope) @@ -12602,9 +12699,7 @@ pub async fn acp_read_agent_skill( let mut skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; - if is_read_only_skill_path(agent_type, Path::new(&skill.path)) { - skill.read_only = true; - } + apply_skill_capabilities(agent_type, &mut skill); let content_path = skill_content_path(skill.layout, Path::new(&skill.path)); let content = fs::read_to_string(&content_path) .map_err(|e| AcpError::protocol(format!("failed to read skill content: {e}")))?; @@ -12653,7 +12748,7 @@ pub async fn acp_save_agent_skill( AgentSkillLayout::SkillDirectory => preferred_dir.join(&id), AgentSkillLayout::MarkdownFile => preferred_dir.join(format!("{id}.md")), }; - build_skill_item(id.clone(), scope, new_layout, skill_path) + build_skill_item(id.clone(), scope, new_layout, skill_path, true) }; let skill_path = PathBuf::from(&skill.path); @@ -15224,6 +15319,469 @@ wire_api = "chat" ); } + #[test] + fn skill_state_active_entry_wins_over_disabled_entries_across_roots() { + let tmp = tempfile::tempdir().expect("tempdir"); + let first = tmp.path().join("first/skills"); + let second = tmp.path().join("second/skills"); + std::fs::create_dir_all(first.join("demo")).expect("create active skill"); + std::fs::write(first.join("demo/SKILL.md"), "active\n").expect("write active skill"); + std::fs::create_dir_all(disabled_skill_root(&second).join("demo")) + .expect("create disabled skill"); + std::fs::write( + disabled_skill_root(&second).join("demo/SKILL.md"), + "disabled\n", + ) + .expect("write disabled skill"); + + assert_eq!( + disabled_skill_root(&first), + tmp.path().join("first/.skills.codeg-disabled") + ); + + let roots = [second, first.clone()]; + let listed = list_skills_from_roots( + AgentSkillScope::Global, + &roots, + SkillStorageKind::SkillDirectoryOnly, + ) + .expect("list skills"); + let located = locate_existing_skill_across_dirs( + &roots, + SkillStorageKind::SkillDirectoryOnly, + "demo", + AgentSkillScope::Global, + ) + .expect("locate skill"); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].path, first.join("demo").to_string_lossy()); + assert!(listed[0].enabled); + assert!(listed[0].can_toggle); + assert_eq!(located.path, first.join("demo").to_string_lossy()); + assert!(located.enabled); + } + + #[test] + fn skill_state_lists_and_locates_disabled_directory_layout() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let disabled = disabled_skill_root(&root).join("demo"); + std::fs::create_dir_all(&disabled).expect("create disabled skill"); + std::fs::write(disabled.join("SKILL.md"), "disabled\n") + .expect("write disabled skill"); + + let listed = list_skills_from_roots( + AgentSkillScope::Project, + std::slice::from_ref(&root), + SkillStorageKind::SkillDirectoryOnly, + ) + .expect("list skills"); + let located = locate_existing_skill_across_dirs( + std::slice::from_ref(&root), + SkillStorageKind::SkillDirectoryOnly, + "demo", + AgentSkillScope::Project, + ) + .expect("locate disabled skill"); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "demo"); + assert_eq!(listed[0].layout, AgentSkillLayout::SkillDirectory); + assert_eq!(listed[0].path, disabled.to_string_lossy()); + assert!(!listed[0].enabled); + assert!(listed[0].can_toggle); + assert_eq!(located.path, disabled.to_string_lossy()); + assert!(!located.enabled); + } + + #[test] + fn skill_state_lists_and_locates_disabled_markdown_layout() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let disabled = disabled_skill_root(&root).join("flat.md"); + std::fs::create_dir_all(disabled.parent().expect("disabled parent")) + .expect("create disabled vault"); + std::fs::write(&disabled, "disabled\n").expect("write disabled skill"); + + let listed = list_skills_from_roots( + AgentSkillScope::Global, + std::slice::from_ref(&root), + SkillStorageKind::SkillDirectoryOrMarkdownFile, + ) + .expect("list skills"); + let located = locate_existing_skill_across_dirs( + std::slice::from_ref(&root), + SkillStorageKind::SkillDirectoryOrMarkdownFile, + "flat", + AgentSkillScope::Global, + ) + .expect("locate disabled skill"); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "flat"); + assert_eq!(listed[0].layout, AgentSkillLayout::MarkdownFile); + assert_eq!(listed[0].path, disabled.to_string_lossy()); + assert!(!listed[0].enabled); + assert_eq!(located.layout, AgentSkillLayout::MarkdownFile); + assert!(!located.enabled); + } + + #[test] + fn skill_state_read_only_builtin_cannot_toggle() { + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { + let system_skill = tmp.path().join("skills/.system/task1a-system-demo"); + std::fs::create_dir_all(&system_skill).expect("create system skill"); + std::fs::write(system_skill.join("SKILL.md"), "system\n") + .expect("write system skill"); + + let listed = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_list_agent_skills(AgentType::Codex, None)) + .expect("list skills"); + let item = listed + .skills + .iter() + .find(|item| item.id == "task1a-system-demo") + .expect("listed system skill"); + + assert!(item.enabled); + assert!(item.read_only); + assert!(!item.can_toggle); + }); + } + + #[test] + fn skill_enabled_private_directory_round_trips_through_disabled_vault() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let skill = root.join("demo"); + std::fs::create_dir_all(&skill).expect("create skill"); + std::fs::write( + skill.join("SKILL.md"), + "---\nname: demo\ndescription: private demo\n---\nbody\n", + ) + .expect("write skill"); + std::fs::write(skill.join("asset.txt"), "asset").expect("write asset"); + + let disabled = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + false, + ) + .expect("disable skill"); + + assert!(!root.join("demo").exists()); + assert!( + disabled_skill_root(&root) + .join("demo") + .join("SKILL.md") + .is_file() + ); + assert_eq!( + std::fs::read_to_string(disabled_skill_root(&root).join("demo/asset.txt")) + .expect("read asset"), + "asset" + ); + assert!(!disabled.enabled); + + let listed = list_skills_from_roots( + AgentSkillScope::Global, + std::slice::from_ref(&root), + SkillStorageKind::SkillDirectoryOnly, + ) + .expect("list disabled skill"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "demo"); + assert!(!listed[0].enabled); + + let enabled = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + true, + ) + .expect("enable skill"); + + assert!(root.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&root).join("demo").exists()); + assert!(enabled.enabled); + } + + #[test] + fn skill_enabled_private_markdown_file_round_trips_without_renaming_id() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + std::fs::create_dir_all(&root).expect("create skills root"); + std::fs::write( + root.join("flat.md"), + "---\nname: flat\ndescription: flat demo\n---\nbody\n", + ) + .expect("write flat skill"); + + let disabled = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOrMarkdownFile, + AgentSkillScope::Project, + "flat", + false, + ) + .expect("disable flat skill"); + + assert!(!root.join("flat.md").exists()); + assert!(disabled_skill_root(&root).join("flat.md").is_file()); + assert_eq!(disabled.layout, AgentSkillLayout::MarkdownFile); + assert_eq!(disabled.scope, AgentSkillScope::Project); + assert!(!disabled.enabled); + + let enabled = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOrMarkdownFile, + AgentSkillScope::Project, + "flat", + true, + ) + .expect("enable flat skill"); + + assert!(root.join("flat.md").is_file()); + assert!(enabled.enabled); + assert_eq!(enabled.id, "flat"); + } + + #[test] + fn skill_enabled_private_listing_prefers_active_and_serializes_state() { + let tmp = tempfile::tempdir().expect("tempdir"); + let first = tmp.path().join("first/skills"); + let second = tmp.path().join("second/skills"); + std::fs::create_dir_all(first.join("demo")).expect("create active skill"); + std::fs::write(first.join("demo/SKILL.md"), "active\n").expect("write active skill"); + std::fs::create_dir_all(disabled_skill_root(&second).join("demo")) + .expect("create disabled skill"); + std::fs::write( + disabled_skill_root(&second).join("demo/SKILL.md"), + "disabled\n", + ) + .expect("write disabled skill"); + + assert_eq!( + disabled_skill_root(&first), + tmp.path().join("first/.skills.codeg-disabled") + ); + let listed = list_skills_from_roots( + AgentSkillScope::Global, + &[second, first.clone()], + SkillStorageKind::SkillDirectoryOnly, + ) + .expect("list skills"); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].path, first.join("demo").to_string_lossy()); + assert!(listed[0].enabled); + assert!(listed[0].can_toggle); + let json = serde_json::to_value(&listed[0]).expect("serialize skill"); + assert_eq!(json["enabled"], true); + assert_eq!(json["can_toggle"], true); + } + + #[test] + fn skill_enabled_private_repeated_requests_are_idempotent() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + std::fs::create_dir_all(root.join("demo")).expect("create skill"); + std::fs::write(root.join("demo/SKILL.md"), "body\n").expect("write skill"); + + for _ in 0..2 { + let skill = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + false, + ) + .expect("disable skill"); + assert!(!skill.enabled); + } + for _ in 0..2 { + let skill = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + true, + ) + .expect("enable skill"); + assert!(skill.enabled); + } + + assert_eq!( + std::fs::read_to_string(root.join("demo/SKILL.md")).expect("read skill"), + "body\n" + ); + assert!(!disabled_skill_root(&root).join("demo").exists()); + } + + #[test] + fn skill_enabled_private_collision_is_rejected_before_move() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let disabled = disabled_skill_root(&root); + std::fs::create_dir_all(root.join("demo")).expect("create active skill"); + std::fs::write(root.join("demo/SKILL.md"), "active\n").expect("write active skill"); + std::fs::create_dir_all(disabled.join("demo")).expect("create disabled collision"); + std::fs::write(disabled.join("demo/SKILL.md"), "disabled\n") + .expect("write disabled collision"); + + let error = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + false, + ) + .expect_err("collision must fail"); + + assert!(error.to_string().contains("collision")); + assert_eq!( + std::fs::read_to_string(root.join("demo/SKILL.md")).expect("read active"), + "active\n" + ); + assert_eq!( + std::fs::read_to_string(disabled.join("demo/SKILL.md")).expect("read disabled"), + "disabled\n" + ); + } + + #[test] + fn skill_enabled_private_disabled_skill_remains_readable_editable_and_deletable() { + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { + let root = tmp.path().join("skills"); + std::fs::create_dir_all(root.join("task1-disabled-demo")) + .expect("create skill"); + std::fs::write( + root.join("task1-disabled-demo/SKILL.md"), + "original\n", + ) + .expect("write skill"); + set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOrMarkdownFile, + AgentSkillScope::Global, + "task1-disabled-demo", + false, + ) + .expect("disable skill"); + + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let read = runtime + .block_on(acp_read_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + "task1-disabled-demo".to_string(), + None, + )) + .expect("read disabled skill"); + assert_eq!(read.content, "original\n"); + assert!(!read.skill.enabled); + + let saved = runtime + .block_on(acp_save_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + "task1-disabled-demo".to_string(), + "updated\n".to_string(), + None, + None, + )) + .expect("save disabled skill"); + assert!(!saved.enabled); + assert!(!root.join("task1-disabled-demo").exists()); + assert_eq!( + std::fs::read_to_string( + disabled_skill_root(&root).join("task1-disabled-demo/SKILL.md") + ) + .expect("read updated skill"), + "updated\n" + ); + + runtime + .block_on(acp_delete_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + "task1-disabled-demo".to_string(), + None, + )) + .expect("delete disabled skill"); + assert!(!disabled_skill_root(&root) + .join("task1-disabled-demo") + .exists()); + }); + } + + #[test] + fn skill_enabled_private_command_is_idempotent() { + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { + let root = tmp.path().join("skills"); + std::fs::create_dir_all(root.join("task1-command-demo")).expect("create skill"); + std::fs::write(root.join("task1-command-demo/SKILL.md"), "body\n") + .expect("write skill"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + + for enabled in [false, false, true, true] { + let item = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Global, + "task1-command-demo".to_string(), + None, + enabled, + )) + .expect("toggle skill"); + assert_eq!(item.enabled, enabled); + } + }); + } + + #[test] + fn skill_enabled_private_read_only_skill_cannot_be_toggled() { + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { + let system_skill = tmp.path().join("skills/.system/task1-system-demo"); + std::fs::create_dir_all(&system_skill).expect("create system skill"); + std::fs::write(system_skill.join("SKILL.md"), "system\n") + .expect("write system skill"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + + let listed = runtime + .block_on(acp_list_agent_skills(AgentType::Codex, None)) + .expect("list skills"); + let item = listed + .skills + .iter() + .find(|item| item.id == "task1-system-demo") + .expect("listed system skill"); + assert!(item.read_only); + assert!(!item.can_toggle); + + let error = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Global, + "task1-system-demo".to_string(), + None, + false, + )) + .expect_err("system skill toggle must fail"); + assert!(error.to_string().contains("cannot be toggled")); + assert!(system_skill.join("SKILL.md").is_file()); + }); + } + #[test] fn parse_provider_model_emits_claude_custom_model_option_trio() { // A Claude provider that defines the custom model option must surface all From 9edf5f2029723e510b07c14250995ed731683908 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:36:24 +0800 Subject: [PATCH 03/23] fix(skills): protect Antigravity CLI skills --- src-tauri/src/commands/acp.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index a81fa38991..a5b3bd9292 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8375,6 +8375,9 @@ fn is_read_only_skill_path(agent_type: AgentType, skill_path: &Path) -> bool { AgentType::DeepSeek => crate::parsers::deepseek::resolve_dsh_home_dir() .join("skills") .join(".system"), + AgentType::Antigravity => { + crate::parsers::antigravity::resolve_antigravity_cli_dir().join("skills") + } _ => return false, }; skill_path.starts_with(&ro_root) @@ -15452,6 +15455,33 @@ wire_api = "chat" }); } + #[test] + fn skill_state_antigravity_cli_skill_cannot_toggle() { + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_var("GEMINI_HOME", Some(tmp.path()), || { + let cli_skill = tmp + .path() + .join("antigravity-cli/skills/task1a-antigravity-cli-demo"); + std::fs::create_dir_all(&cli_skill).expect("create CLI skill"); + std::fs::write(cli_skill.join("SKILL.md"), "CLI-owned\n") + .expect("write CLI skill"); + + let listed = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_list_agent_skills(AgentType::Antigravity, None)) + .expect("list skills"); + let item = listed + .skills + .iter() + .find(|item| item.id == "task1a-antigravity-cli-demo") + .expect("listed Antigravity CLI skill"); + + assert!(item.enabled); + assert!(item.read_only); + assert!(!item.can_toggle); + }); + } + #[test] fn skill_enabled_private_directory_round_trips_through_disabled_vault() { let tmp = tempfile::tempdir().expect("tempdir"); From 900483d47718cf9a3d17d715e83995d3a3431cbc Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:55:50 +0800 Subject: [PATCH 04/23] feat(skills): implement private skill enable toggles --- src-tauri/src/commands/acp.rs | 578 ++++++++++++++++++++++++++++++++-- 1 file changed, 556 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index a5b3bd9292..b6df23929e 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8615,30 +8615,185 @@ pub(crate) fn locate_existing_skill_across_dirs( None } -#[cfg(test)] +// All settings mutations share this lock so lookup, preflight and mutation +// observe one state. No guard is held across an await. +static SKILL_MUTATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn preflight_skill_symlink_move(source: &Path, destination_root: &Path) -> Result<(), AcpError> { + let metadata = fs::symlink_metadata(source) + .map_err(|e| AcpError::protocol(format!("failed to inspect skill entry: {e}")))?; + if !metadata.file_type().is_symlink() { + return Ok(()); + } + let target = fs::read_link(source) + .map_err(|e| AcpError::protocol(format!("failed to read skill symlink: {e}")))?; + if target.is_absolute() { + return Ok(()); + } + let original_target = fs::canonicalize(source) + .map_err(|e| AcpError::protocol(format!("failed to resolve skill symlink: {e}")))?; + let moved_target = if destination_root.exists() { + fs::canonicalize(destination_root.join(&target)) + } else { + let parent = destination_root + .parent() + .ok_or_else(|| AcpError::protocol("skill destination has no parent"))?; + let name = destination_root + .file_name() + .ok_or_else(|| AcpError::protocol("skill destination has no directory name"))?; + let mut future_root = fs::canonicalize(parent) + .map_err(|e| AcpError::protocol(format!("failed to resolve skill destination: {e}")))? + .join(name); + // The future root is a new directory, so leading parent components + // can be evaluated without creating it. Keep subsequent components + // intact so canonicalize still follows any symlinks in the target. + let mut remaining = target.components(); + loop { + match remaining.clone().next() { + Some(std::path::Component::CurDir) => { + remaining.next(); + } + Some(std::path::Component::ParentDir) => { + future_root.pop(); + remaining.next(); + } + _ => break, + } + } + fs::canonicalize(future_root.join(remaining.as_path())) + }; + if moved_target.ok().as_ref() != Some(&original_target) { + return Err(AcpError::protocol(format!( + "relative symlink '{}' would resolve to a different or missing target after moving", + source.display() + ))); + } + Ok(()) +} + +/// The caller must hold SKILL_MUTATION_LOCK when serving a backend command. fn set_private_skill_enabled( - _root: &Path, - _kind: SkillStorageKind, - _scope: AgentSkillScope, - _skill_id: &str, - _enabled: bool, + root: &Path, + kind: SkillStorageKind, + scope: AgentSkillScope, + skill_id: &str, + enabled: bool, ) -> Result { - Err(AcpError::protocol( - "private skill moves are not implemented in task 1A", + let id = validate_skill_id(skill_id)?; + let roots = [root.to_path_buf()]; + let skill = locate_existing_skill_across_dirs(&roots, kind, &id, scope) + .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; + if skill.enabled == enabled { + return Ok(skill); + } + + let vault = disabled_skill_root(root); + let destination_root = if enabled { root } else { &vault }; + let source = Path::new(&skill.path); + let file_name = source + .file_name() + .ok_or_else(|| AcpError::protocol("skill entry has no file name"))?; + let destination = destination_root.join(file_name); + let mut candidates = vec![destination.clone(), destination_root.join(&id)]; + if matches!(kind, SkillStorageKind::SkillDirectoryOrMarkdownFile) { + candidates.push(destination_root.join(format!("{id}.md"))); + } + for candidate in candidates { + match fs::symlink_metadata(&candidate) { + Ok(_) => { + return Err(AcpError::protocol(format!( + "skill destination collision: '{}' already exists", + candidate.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(AcpError::protocol(format!( + "failed to inspect skill destination '{}': {error}", + candidate.display() + ))); + } + } + } + + preflight_skill_symlink_move(source, destination_root)?; + fs::create_dir_all(destination_root) + .map_err(|e| AcpError::protocol(format!("failed to create skills directory: {e}")))?; + // Rename the entry itself, including symlinks; never copy/dereference a + // bundle whose supporting files may belong to a separate central store. + fs::rename(source, &destination) + .map_err(|e| AcpError::protocol(format!("failed to move skill '{id}': {e}")))?; + Ok(build_skill_item( + id, + scope, + skill.layout, + destination, + enabled, )) } -#[cfg(test)] -async fn acp_set_agent_skill_enabled( - _agent_type: AgentType, - _scope: AgentSkillScope, - _skill_id: String, - _workspace_path: Option, - _enabled: bool, -) -> Result { - Err(AcpError::protocol( - "skill toggle command is not implemented in task 1A", - )) +fn skill_root_is_shared( + agent_type: AgentType, + scope: AgentSkillScope, + workspace_path: Option<&str>, + root: &Path, +) -> bool { + let resolved_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + registry::all_acp_agents().into_iter().any(|peer| { + peer != agent_type + && scoped_skill_dirs(peer, scope, workspace_path) + .unwrap_or_default() + .iter() + .any(|peer_root| { + *peer_root == root + || fs::canonicalize(peer_root) + .map(|path| path == resolved_root) + .unwrap_or(false) + }) + }) +} + +fn reject_multiple_active_skills( + roots: &[PathBuf], + kind: SkillStorageKind, + skill_id: &str, +) -> Result<(), AcpError> { + let mut matches = 0; + for root in roots { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(AcpError::protocol(format!( + "failed to inspect active skills directory '{}': {error}", + root.display() + ))); + } + }; + // Listing intentionally deduplicates IDs; preflight must count both + // layouts so disabling a bundle cannot reveal its same-ID flat file. + for entry in entries { + let path = entry + .map_err(|e| AcpError::protocol(format!("failed to inspect active skill: {e}")))? + .path(); + let directory_match = path.file_name().and_then(|name| name.to_str()) == Some(skill_id) + && path.is_dir() + && path.join("SKILL.md").is_file(); + let markdown_match = matches!(kind, SkillStorageKind::SkillDirectoryOrMarkdownFile) + && path.file_stem().and_then(|name| name.to_str()) == Some(skill_id) + && is_markdown_file(&path) + && path.is_file(); + if directory_match || markdown_match { + matches += 1; + if matches > 1 { + return Err(AcpError::protocol(format!( + "multiple active skills share id '{skill_id}'; resolve duplicates before toggling" + ))); + } + } + } + } + Ok(()) } #[derive(Debug, Clone, Default, Deserialize)] @@ -12685,6 +12840,58 @@ pub async fn acp_list_agent_skills( }) } +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_set_agent_skill_enabled( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + enabled: bool, +) -> Result { + let _guard = SKILL_MUTATION_LOCK + .lock() + .map_err(|_| AcpError::protocol("skill mutation lock poisoned"))?; + let spec = skill_storage_spec(agent_type).ok_or_else(|| { + AcpError::protocol(format!( + "{agent_type} skills are not supported in Settings yet" + )) + })?; + let id = validate_skill_id(&skill_id)?; + let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; + let mut skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) + .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; + apply_skill_capabilities(agent_type, &mut skill); + let parent = Path::new(&skill.path).parent(); + let root = dirs + .iter() + .find(|root| { + if skill.enabled { + parent == Some(root.as_path()) + } else { + parent == Some(disabled_skill_root(root).as_path()) + } + }) + .ok_or_else(|| AcpError::protocol("skill has no owning native root"))?; + if !skill.can_toggle || is_read_only_skill_path(agent_type, root) { + return Err(AcpError::protocol(format!( + "skill '{id}' is a built-in system skill and cannot be toggled" + ))); + } + if skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), root) { + // Task 2 owns peer fan-out and per-agent isolation for shared roots. + return Err(AcpError::protocol(format!( + "shared skill root '{}' requires shared-root toggle support", + root.display() + ))); + } + reject_multiple_active_skills(&dirs, spec.kind, &id)?; + set_private_skill_enabled(root, spec.kind, scope, &id, enabled)?; + let mut authoritative = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) + .ok_or_else(|| AcpError::protocol(format!("skill not found after toggle: {id}")))?; + apply_skill_capabilities(agent_type, &mut authoritative); + Ok(authoritative) +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_read_agent_skill( agent_type: AgentType, @@ -12692,6 +12899,9 @@ pub async fn acp_read_agent_skill( skill_id: String, workspace_path: Option, ) -> Result { + let _guard = SKILL_MUTATION_LOCK + .lock() + .map_err(|_| AcpError::protocol("skill mutation lock poisoned"))?; let Some(spec) = skill_storage_spec(agent_type) else { return Err(AcpError::protocol(format!( "{agent_type} skills are not supported in Settings yet" @@ -12718,6 +12928,9 @@ pub async fn acp_save_agent_skill( workspace_path: Option, layout: Option, ) -> Result { + let _guard = SKILL_MUTATION_LOCK + .lock() + .map_err(|_| AcpError::protocol("skill mutation lock poisoned"))?; let Some(spec) = skill_storage_spec(agent_type) else { return Err(AcpError::protocol(format!( "{agent_type} skills are not supported in Settings yet" @@ -12727,9 +12940,6 @@ pub async fn acp_save_agent_skill( let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; let preferred_dir = preferred_scope_skill_dir(agent_type, scope, workspace_path.as_deref())?; - fs::create_dir_all(&preferred_dir) - .map_err(|e| AcpError::protocol(format!("failed to create skills directory: {e}")))?; - let existing = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope); if let Some(ref item) = existing { if is_read_only_skill_path(agent_type, Path::new(&item.path)) { @@ -12785,6 +12995,9 @@ pub async fn acp_delete_agent_skill( skill_id: String, workspace_path: Option, ) -> Result<(), AcpError> { + let _guard = SKILL_MUTATION_LOCK + .lock() + .map_err(|_| AcpError::protocol("skill mutation lock poisoned"))?; let Some(spec) = skill_storage_spec(agent_type) else { return Err(AcpError::protocol(format!( "{agent_type} skills are not supported in Settings yet" @@ -15777,6 +15990,327 @@ wire_api = "chat" }); } + #[cfg(unix)] + #[test] + fn skill_enabled_private_symlink_round_trip_preserves_link_and_target() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let target = tmp.path().join("target"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("SKILL.md"), "linked body").unwrap(); + std::os::unix::fs::symlink("../target", root.join("linked")).unwrap(); + + for enabled in [false, true] { + let item = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "linked", + enabled, + ) + .unwrap(); + assert_eq!(fs::read_link(&item.path).unwrap(), Path::new("../target")); + assert_eq!( + fs::read_to_string(target.join("SKILL.md")).unwrap(), + "linked body" + ); + assert_eq!(item.enabled, enabled); + } + } + + #[test] + fn skill_enabled_private_collision_checks_other_layout_before_mutation() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let vault = disabled_skill_root(&root); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "active").unwrap(); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("demo.md"), "disabled").unwrap(); + + let error = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOrMarkdownFile, + AgentSkillScope::Global, + "demo", + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("collision")); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "active" + ); + assert_eq!( + fs::read_to_string(vault.join("demo.md")).unwrap(), + "disabled" + ); + } + + #[cfg(unix)] + #[test] + fn skill_enabled_private_relative_symlink_target_change_is_rejected_before_move() { + for vault_exists in [false, true] { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let vault = disabled_skill_root(&root); + fs::create_dir_all(root.join("target")).unwrap(); + fs::write(root.join("target/SKILL.md"), "original target").unwrap(); + std::os::unix::fs::symlink("target", root.join("demo")).unwrap(); + if vault_exists { + fs::create_dir_all(vault.join("target")).unwrap(); + fs::write(vault.join("target/SKILL.md"), "different target").unwrap(); + } + let error = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + false, + ) + .unwrap_err(); + assert!(error.to_string().contains("relative symlink")); + assert_eq!( + fs::read_link(root.join("demo")).unwrap(), + Path::new("target") + ); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "original target" + ); + assert!(!vault.join("demo").exists()); + assert_eq!(vault.exists(), vault_exists); + } + } + + #[cfg(unix)] + #[test] + fn skill_enabled_private_absolute_symlink_round_trip_preserves_link() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let target = tmp.path().join("target.md"); + fs::create_dir_all(&root).unwrap(); + fs::write(&target, "absolute target").unwrap(); + std::os::unix::fs::symlink(&target, root.join("demo.md")).unwrap(); + for enabled in [false, true] { + let item = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOrMarkdownFile, + AgentSkillScope::Global, + "demo", + enabled, + ) + .unwrap(); + assert_eq!(fs::read_link(&item.path).unwrap(), target); + assert_eq!(fs::read_to_string(item.path).unwrap(), "absolute target"); + } + } + + #[cfg(unix)] + #[test] + fn skill_enabled_private_enable_rejects_dangling_destination_link() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("skills"); + let vault = disabled_skill_root(&root); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&vault).unwrap(); + fs::write(vault.join("demo.md"), "disabled").unwrap(); + std::os::unix::fs::symlink("missing", root.join("demo.md")).unwrap(); + + let error = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOrMarkdownFile, + AgentSkillScope::Global, + "demo", + true, + ) + .unwrap_err(); + assert!(error.to_string().contains("collision")); + assert_eq!( + fs::read_link(root.join("demo.md")).unwrap(), + Path::new("missing") + ); + assert_eq!( + fs::read_to_string(vault.join("demo.md")).unwrap(), + "disabled" + ); + } + + #[test] + fn skill_enabled_private_command_leaves_shared_roots_for_task_two() { + let tmp = tempfile::tempdir().expect("tempdir"); + let runtime = tokio::runtime::Runtime::new().unwrap(); + for (agent, relative) in [ + (AgentType::Codex, ".agents/skills"), + (AgentType::ClaudeCode, ".claude/skills"), + (AgentType::Gemini, ".gemini/skills"), + ] { + let root = tmp.path().join(relative); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "shared").unwrap(); + let error = runtime + .block_on(acp_set_agent_skill_enabled( + agent, + AgentSkillScope::Project, + "demo".to_string(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )) + .unwrap_err(); + assert!(error.to_string().contains("shared skill root")); + assert!(root.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&root).exists()); + } + } + + #[test] + fn skill_enabled_private_concurrent_commands_return_requested_state() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join(".codex/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "body").unwrap(); + let workspace = tmp.path().to_string_lossy().into_owned(); + std::thread::scope(|threads| { + let handles = (0..8) + .map(|_| { + let workspace = &workspace; + threads.spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + for enabled in [false, false, true, true] { + let item = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".to_string(), + Some(workspace.clone()), + enabled, + )) + .unwrap(); + assert_eq!(item.enabled, enabled); + } + }) + }) + .collect::>(); + for handle in handles { + handle.join().unwrap(); + } + }); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "body" + ); + assert!(!disabled_skill_root(&root).join("demo").exists()); + } + + #[test] + fn skill_enabled_private_duplicate_active_roots_are_rejected_before_move() { + let tmp = tempfile::tempdir().expect("tempdir"); + let roots = [ + tmp.path().join(".codex/skills"), + tmp.path().join(".agents/skills"), + ]; + for root in &roots { + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "original").unwrap(); + } + let runtime = tokio::runtime::Runtime::new().unwrap(); + let error = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )) + .unwrap_err(); + assert!(error.to_string().contains("multiple active")); + for root in &roots { + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "original" + ); + assert!(!disabled_skill_root(root).exists()); + } + } + + #[test] + fn skill_enabled_private_duplicate_active_layouts_are_rejected_before_move() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join(".codex/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "directory").unwrap(); + fs::write(root.join("demo.md"), "flat").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let error = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )) + .unwrap_err(); + assert!(error.to_string().contains("multiple active")); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "directory" + ); + assert_eq!(fs::read_to_string(root.join("demo.md")).unwrap(), "flat"); + assert!(!disabled_skill_root(&root).exists()); + } + + #[test] + fn skill_enabled_private_save_new_then_edit_disabled_and_reenable() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = Some(tmp.path().to_string_lossy().into_owned()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let saved = runtime + .block_on(acp_save_agent_skill( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + "original".into(), + workspace.clone(), + None, + )) + .unwrap(); + assert!(saved.enabled); + assert_eq!(saved.layout, AgentSkillLayout::MarkdownFile); + runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + workspace.clone(), + false, + )) + .unwrap(); + let saved = runtime + .block_on(acp_save_agent_skill( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + "updated".into(), + workspace.clone(), + Some(AgentSkillLayout::SkillDirectory), + )) + .unwrap(); + assert!(!saved.enabled); + assert_eq!(saved.layout, AgentSkillLayout::MarkdownFile); + let enabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + workspace, + true, + )) + .unwrap(); + assert!(enabled.enabled); + assert_eq!(fs::read_to_string(enabled.path).unwrap(), "updated"); + } + #[test] fn skill_enabled_private_read_only_skill_cannot_be_toggled() { let tmp = tempfile::tempdir().expect("tempdir"); From 1e4ae3e90b862e3fdf6ff4e70d2a705fb093ea21 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:14:07 +0800 Subject: [PATCH 05/23] fix(skills): preflight move roots and bundle symlinks --- src-tauri/src/commands/acp.rs | 612 ++++++++++++++++++++++++++++++---- 1 file changed, 550 insertions(+), 62 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index b6df23929e..5e1274a3a0 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8619,54 +8619,195 @@ pub(crate) fn locate_existing_skill_across_dirs( // observe one state. No guard is held across an await. static SKILL_MUTATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -fn preflight_skill_symlink_move(source: &Path, destination_root: &Path) -> Result<(), AcpError> { - let metadata = fs::symlink_metadata(source) - .map_err(|e| AcpError::protocol(format!("failed to inspect skill entry: {e}")))?; - if !metadata.file_type().is_symlink() { - return Ok(()); +fn resolved_skill_root(root: &Path) -> Result { + let absolute = if root.is_absolute() { + root.to_path_buf() + } else { + std::env::current_dir() + .map_err(|e| AcpError::protocol(format!("failed to resolve current directory: {e}")))? + .join(root) + }; + let mut ancestor = absolute.as_path(); + let mut suffix = Vec::new(); + loop { + match fs::canonicalize(ancestor) { + Ok(mut resolved) => { + for component in suffix.into_iter().rev() { + resolved.push(component); + } + return Ok(resolved); + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && fs::symlink_metadata(ancestor).is_err() => + { + let Some(name) = ancestor.file_name() else { + return Err(AcpError::protocol(format!( + "failed to resolve skill root '{}': {error}", + root.display() + ))); + }; + suffix.push(name.to_os_string()); + ancestor = ancestor + .parent() + .ok_or_else(|| AcpError::protocol("skill root has no parent"))?; + } + Err(error) => { + return Err(AcpError::protocol(format!( + "failed to resolve skill root '{}': {error}", + root.display() + ))) + } + } } - let target = fs::read_link(source) - .map_err(|e| AcpError::protocol(format!("failed to read skill symlink: {e}")))?; - if target.is_absolute() { - return Ok(()); +} + +/// Resolve a path against the filesystem as it would look after one rename. +/// Destination entries are inspected at their source paths, while symlink +/// targets are interpreted from their future parents. The old entry is absent. +fn resolve_skill_path_after_move( + path: &Path, + source: &Path, + destination: &Path, + directory: bool, + followed_links: usize, +) -> std::io::Result { + if followed_links > 40 { + return Err(std::io::Error::other("too many skill symlinks")); + } + let mut resolved = PathBuf::new(); + let mut components = path.components(); + while let Some(component) = components.next() { + match component { + std::path::Component::CurDir => continue, + std::path::Component::ParentDir => { + resolved.pop(); + continue; + } + _ => resolved.push(component.as_os_str()), + } + if matches!( + component, + std::path::Component::Prefix(_) | std::path::Component::RootDir + ) { + continue; + } + // The sibling root may be created by the move, but no other missing + // path is treated as present during preflight. + if Some(resolved.as_path()) == destination.parent() { + continue; + } + let physical = if resolved == destination { + source.to_path_buf() + } else if directory && resolved.starts_with(destination) { + source.join( + resolved + .strip_prefix(destination) + .expect("checked destination prefix"), + ) + } else if resolved == source || (directory && resolved.starts_with(source)) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "target remains in old skill location", + )); + } else { + resolved.clone() + }; + let metadata = fs::symlink_metadata(&physical)?; + if metadata.file_type().is_symlink() { + let target = fs::read_link(physical)?; + let next = if target.is_absolute() { + target + } else { + resolved + .parent() + .unwrap_or_else(|| Path::new("")) + .join(target) + }; + return resolve_skill_path_after_move( + &next.join(components.as_path()), + source, + destination, + directory, + followed_links + 1, + ); + } + if components.clone().next().is_some() && !metadata.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotADirectory, + "skill target component is not a directory", + )); + } } - let original_target = fs::canonicalize(source) - .map_err(|e| AcpError::protocol(format!("failed to resolve skill symlink: {e}")))?; - let moved_target = if destination_root.exists() { - fs::canonicalize(destination_root.join(&target)) - } else { - let parent = destination_root + Ok(resolved) +} + +fn preflight_skill_symlink_move(source: &Path, destination_root: &Path) -> Result<(), AcpError> { + let source = resolved_skill_root( + source .parent() - .ok_or_else(|| AcpError::protocol("skill destination has no parent"))?; - let name = destination_root + .ok_or_else(|| AcpError::protocol("skill entry has no parent"))?, + )? + .join( + source .file_name() - .ok_or_else(|| AcpError::protocol("skill destination has no directory name"))?; - let mut future_root = fs::canonicalize(parent) - .map_err(|e| AcpError::protocol(format!("failed to resolve skill destination: {e}")))? - .join(name); - // The future root is a new directory, so leading parent components - // can be evaluated without creating it. Keep subsequent components - // intact so canonicalize still follows any symlinks in the target. - let mut remaining = target.components(); - loop { - match remaining.clone().next() { - Some(std::path::Component::CurDir) => { - remaining.next(); - } - Some(std::path::Component::ParentDir) => { - future_root.pop(); - remaining.next(); - } - _ => break, + .ok_or_else(|| AcpError::protocol("skill entry has no filename"))?, + ); + let destination = + resolved_skill_root(destination_root)?.join(source.file_name().expect("checked filename")); + let directory = fs::symlink_metadata(&source) + .map_err(|e| AcpError::protocol(format!("failed to inspect skill entry: {e}")))? + .is_dir(); + let mut pending = vec![source.clone()]; + while let Some(entry) = pending.pop() { + let metadata = fs::symlink_metadata(&entry) + .map_err(|e| AcpError::protocol(format!("failed to inspect skill entry: {e}")))?; + if metadata.file_type().is_symlink() { + let target = fs::read_link(&entry) + .map_err(|e| AcpError::protocol(format!("failed to read skill symlink: {e}")))?; + let original_target = fs::canonicalize(&entry).map_err(|e| { + AcpError::protocol(format!( + "failed to resolve skill symlink '{}': {e}", + entry.display() + )) + })?; + let expected_target = if directory && original_target.starts_with(&source) { + destination.join( + original_target + .strip_prefix(&source) + .expect("checked source prefix"), + ) + } else { + original_target + }; + let future_entry = + destination.join(entry.strip_prefix(&source).expect("entry is in bundle")); + let moved_target = + resolve_skill_path_after_move(&future_entry, &source, &destination, directory, 0); + if moved_target.ok().as_ref() != Some(&expected_target) { + return Err(AcpError::protocol(format!( + "{}symlink '{}' would resolve to a different or missing target after moving", + if target.is_relative() { + "relative " + } else { + "" + }, + entry.display() + ))); + } + } else if metadata.is_dir() { + for child in fs::read_dir(&entry) + .map_err(|e| AcpError::protocol(format!("failed to inspect skill bundle: {e}")))? + { + pending.push( + child + .map_err(|e| { + AcpError::protocol(format!("failed to inspect skill bundle entry: {e}")) + })? + .path(), + ); } } - fs::canonicalize(future_root.join(remaining.as_path())) - }; - if moved_target.ok().as_ref() != Some(&original_target) { - return Err(AcpError::protocol(format!( - "relative symlink '{}' would resolve to a different or missing target after moving", - source.display() - ))); } Ok(()) } @@ -8732,25 +8873,66 @@ fn set_private_skill_enabled( )) } +fn native_skill_roots(workspace_path: Option<&str>) -> Vec<(AgentType, AgentSkillScope, PathBuf)> { + let mut roots = Vec::new(); + for agent in registry::all_acp_agents() { + for scope in [AgentSkillScope::Global, AgentSkillScope::Project] { + if scope == AgentSkillScope::Project + && workspace_path + .map(str::trim) + .filter(|p| !p.is_empty()) + .is_none() + { + continue; + } + roots.extend( + scoped_skill_dirs(agent, scope, workspace_path) + .unwrap_or_default() + .into_iter() + .map(|root| (agent, scope, root)), + ); + } + } + roots +} + +fn skill_roots_overlap(first: &Path, second: &Path) -> bool { + first.starts_with(second) || second.starts_with(first) +} + fn skill_root_is_shared( agent_type: AgentType, scope: AgentSkillScope, workspace_path: Option<&str>, root: &Path, -) -> bool { - let resolved_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); - registry::all_acp_agents().into_iter().any(|peer| { - peer != agent_type - && scoped_skill_dirs(peer, scope, workspace_path) - .unwrap_or_default() - .iter() - .any(|peer_root| { - *peer_root == root - || fs::canonicalize(peer_root) - .map(|path| path == resolved_root) - .unwrap_or(false) - }) - }) +) -> Result { + let resolved_root = resolved_skill_root(root)?; + for (peer, peer_scope, peer_root) in native_skill_roots(workspace_path) { + if (peer != agent_type || peer_scope != scope) + && skill_roots_overlap(&resolved_root, &resolved_skill_root(&peer_root)?) + { + return Ok(true); + } + } + Ok(false) +} + +fn preflight_disabled_skill_root( + root: &Path, + workspace_path: Option<&str>, +) -> Result<(), AcpError> { + let vault = disabled_skill_root(root); + let resolved_vault = resolved_skill_root(&vault)?; + for (_, _, native_root) in native_skill_roots(workspace_path) { + if skill_roots_overlap(&resolved_vault, &resolved_skill_root(&native_root)?) { + return Err(AcpError::protocol(format!( + "disabled skill vault '{}' overlaps native scan root '{}'", + vault.display(), + native_root.display() + ))); + } + } + Ok(()) } fn reject_multiple_active_skills( @@ -8796,6 +8978,42 @@ fn reject_multiple_active_skills( Ok(()) } +fn finish_private_skill_toggle( + agent_type: AgentType, + dirs: &[PathBuf], + kind: SkillStorageKind, + original: &AgentSkillItem, + moved: AgentSkillItem, + enabled: bool, +) -> Result { + let authoritative = list_skills_from_roots(original.scope, dirs, kind) + .map(|items| items.into_iter().find(|item| item.id == original.id)); + if let Ok(Some(mut item)) = authoritative { + if item.enabled == enabled { + apply_skill_capabilities(agent_type, &mut item); + return Ok(item); + } + } + if moved.path != original.path { + match fs::symlink_metadata(&original.path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err(AcpError::protocol(format!( + "requested skill state was not reached; rollback refused because original path '{}' is occupied or inaccessible; moved entry remains at '{}'", + original.path, moved.path + ))), + } + fs::rename(&moved.path, &original.path).map_err(|error| { + AcpError::protocol(format!( + "requested skill state was not reached; rollback from '{}' to '{}' failed: {error}", + moved.path, original.path + )) + })?; + } + Err(AcpError::protocol( + "requested skill state was not reached; skill move rolled back", + )) +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeConfig { @@ -12877,7 +13095,7 @@ pub async fn acp_set_agent_skill_enabled( "skill '{id}' is a built-in system skill and cannot be toggled" ))); } - if skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), root) { + if skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), root)? { // Task 2 owns peer fan-out and per-agent isolation for shared roots. return Err(AcpError::protocol(format!( "shared skill root '{}' requires shared-root toggle support", @@ -12885,11 +13103,11 @@ pub async fn acp_set_agent_skill_enabled( ))); } reject_multiple_active_skills(&dirs, spec.kind, &id)?; - set_private_skill_enabled(root, spec.kind, scope, &id, enabled)?; - let mut authoritative = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) - .ok_or_else(|| AcpError::protocol(format!("skill not found after toggle: {id}")))?; - apply_skill_capabilities(agent_type, &mut authoritative); - Ok(authoritative) + if skill.enabled != enabled { + preflight_disabled_skill_root(root, workspace_path.as_deref())?; + } + let moved = set_private_skill_enabled(root, spec.kind, scope, &id, enabled)?; + finish_private_skill_toggle(agent_type, &dirs, spec.kind, &skill, moved, enabled) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] @@ -16311,6 +16529,276 @@ wire_api = "chat" assert_eq!(fs::read_to_string(enabled.path).unwrap(), "updated"); } + #[test] + fn skill_enabled_private_safety_postcondition_mismatch_rolls_back() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("private/skills"); + let other = tmp.path().join("other/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "original").unwrap(); + let original = locate_existing_skill( + &root, + SkillStorageKind::SkillDirectoryOnly, + "demo", + AgentSkillScope::Project, + true, + ) + .unwrap(); + let moved = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Project, + "demo", + false, + ) + .unwrap(); + fs::create_dir_all(other.join("demo")).unwrap(); + fs::write(other.join("demo/SKILL.md"), "concurrent copy").unwrap(); + let result = finish_private_skill_toggle( + AgentType::Codex, + &[root.clone(), other.clone()], + SkillStorageKind::SkillDirectoryOnly, + &original, + moved, + false, + ); + assert!( + result.is_err(), + "requested state must be a postcondition: {result:?}" + ); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "original" + ); + assert_eq!( + fs::read_to_string(other.join("demo/SKILL.md")).unwrap(), + "concurrent copy" + ); + assert!(!disabled_skill_root(&root).join("demo").exists()); + } + + #[test] + fn skill_enabled_private_safety_missing_post_move_entry_rolls_back() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "original").unwrap(); + let original = locate_existing_skill( + &root, + SkillStorageKind::SkillDirectoryOnly, + "demo", + AgentSkillScope::Project, + true, + ) + .unwrap(); + let moved = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Project, + "demo", + false, + ) + .unwrap(); + fs::rename( + Path::new(&moved.path).join("SKILL.md"), + Path::new(&moved.path).join("body.saved"), + ) + .unwrap(); + let result = finish_private_skill_toggle( + AgentType::Codex, + std::slice::from_ref(&root), + SkillStorageKind::SkillDirectoryOnly, + &original, + moved, + false, + ); + assert!(result.is_err()); + assert_eq!( + fs::read_to_string(root.join("demo/body.saved")).unwrap(), + "original" + ); + assert!(!disabled_skill_root(&root).join("demo").exists()); + } + + #[test] + #[cfg(unix)] + fn skill_enabled_private_safety_vault_alias_to_native_root_is_rejected() { + for destination in [".agents/skills", "skills"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".codex/skills"); + let other = tmp.path().join(destination); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "original").unwrap(); + fs::create_dir_all(&other).unwrap(); + std::os::unix::fs::symlink(&other, disabled_skill_root(&root)).unwrap(); + let result = + tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )); + assert!( + result.is_err(), + "vault must not alias any native scan root: {result:?}" + ); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "original" + ); + assert!(!other.join("demo").exists()); + assert_eq!(fs::read_link(disabled_skill_root(&root)).unwrap(), other); + } + } + + #[test] + #[cfg(unix)] + fn skill_enabled_private_safety_bundle_content_link_escape_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("body.txt"), "original content").unwrap(); + std::os::unix::fs::symlink("../body.txt", root.join("demo/SKILL.md")).unwrap(); + let result = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + false, + ); + assert!( + result.is_err(), + "content link must retain its target: {result:?}" + ); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "original content" + ); + assert!(!disabled_skill_root(&root).exists()); + } + + #[test] + #[cfg(unix)] + fn skill_enabled_private_safety_bundle_nested_asset_escape_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("skills"); + fs::create_dir_all(root.join("demo/assets")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "body").unwrap(); + fs::write(root.join("asset.txt"), "original asset").unwrap(); + std::os::unix::fs::symlink("../../asset.txt", root.join("demo/assets/link")).unwrap(); + let result = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + false, + ); + assert!( + result.is_err(), + "nested asset link must retain its target: {result:?}" + ); + assert_eq!( + fs::read_to_string(root.join("demo/assets/link")).unwrap(), + "original asset" + ); + assert!(!disabled_skill_root(&root).exists()); + } + + #[test] + #[cfg(unix)] + fn skill_enabled_private_safety_internal_bundle_links_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("skills"); + fs::create_dir_all(root.join("demo/docs")).unwrap(); + fs::create_dir_all(root.join("demo/assets")).unwrap(); + fs::write(root.join("demo/docs/body.md"), "internal content").unwrap(); + std::os::unix::fs::symlink("docs/body.md", root.join("demo/SKILL.md")).unwrap(); + std::os::unix::fs::symlink("../docs/body.md", root.join("demo/assets/link")).unwrap(); + for enabled in [false, true] { + let item = set_private_skill_enabled( + &root, + SkillStorageKind::SkillDirectoryOnly, + AgentSkillScope::Global, + "demo", + enabled, + ) + .unwrap(); + let path = Path::new(&item.path); + assert_eq!( + fs::read_to_string(path.join("SKILL.md")).unwrap(), + "internal content" + ); + assert_eq!( + fs::read_to_string(path.join("assets/link")).unwrap(), + "internal content" + ); + assert_eq!( + fs::read_link(path.join("SKILL.md")).unwrap(), + Path::new("docs/body.md") + ); + } + } + + #[test] + fn skill_enabled_private_safety_project_root_shared_with_custom_global_is_rejected() { + use crate::acp::custom_registry::{ + hydrate, hydrate_test_guard, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, + NpxSpec, + }; + let _registry_guard = hydrate_test_guard(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".codex/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "shared").unwrap(); + let definition = CustomAgentDef { + registry_id: "task1b-cross-scope".into(), + name: "Cross Scope".into(), + description: String::new(), + version: "1.0.0".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "test-agent@1.0.0".into(), + ..Default::default() + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: Some(root.to_string_lossy().into_owned()), + source: Default::default(), + version_probe: None, + supports_mcp: true, + }; + assert!(hydrate(&[definition]).is_empty()); + let result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )); + hydrate(&[]); + assert!( + result.is_err(), + "global peer root must count as shared: {result:?}" + ); + assert!(result + .unwrap_err() + .to_string() + .contains("shared skill root")); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "shared" + ); + assert!(!disabled_skill_root(&root).exists()); + } + #[test] fn skill_enabled_private_read_only_skill_cannot_be_toggled() { let tmp = tempfile::tempdir().expect("tempdir"); From 4099dd782c9e0b2c2a0cd9d75d3f83a4b1b9bd53 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:26:19 +0800 Subject: [PATCH 06/23] fix(skills): reject shared disabled vault ownership --- src-tauri/src/commands/acp.rs | 167 +++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 5e1274a3a0..5d7c455ad2 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8923,14 +8923,24 @@ fn preflight_disabled_skill_root( ) -> Result<(), AcpError> { let vault = disabled_skill_root(root); let resolved_vault = resolved_skill_root(&vault)?; + let resolved_root = resolved_skill_root(root)?; for (_, _, native_root) in native_skill_roots(workspace_path) { - if skill_roots_overlap(&resolved_vault, &resolved_skill_root(&native_root)?) { + let resolved_native = resolved_skill_root(&native_root)?; + if skill_roots_overlap(&resolved_vault, &resolved_native) { return Err(AcpError::protocol(format!( "disabled skill vault '{}' overlaps native scan root '{}'", vault.display(), native_root.display() ))); } + if resolved_native != resolved_root + && resolved_vault == resolved_skill_root(&disabled_skill_root(&native_root))? + { + return Err(AcpError::protocol(format!( + "shared skill storage: disabled vault '{}' is shared by native roots '{}' and '{}'; toggling is unsupported", + vault.display(), root.display(), native_root.display() + ))); + } } Ok(()) } @@ -16799,6 +16809,161 @@ wire_api = "chat" assert!(!disabled_skill_root(&root).exists()); } + #[test] + fn skill_enabled_private_safety_sibling_custom_roots_cannot_share_vault() { + use crate::acp::custom_registry::{ + hydrate, hydrate_test_guard, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, + NpxSpec, + }; + let _registry_guard = hydrate_test_guard(); + for enabled in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let first_root = tmp.path().join("agent-a"); + let second_root = tmp.path().join("agent-b"); + fs::create_dir_all(&first_root).unwrap(); + fs::create_dir_all(&second_root).unwrap(); + let vault = disabled_skill_root(&first_root); + assert_eq!(vault, disabled_skill_root(&second_root)); + let source = if enabled { + vault.join("demo") + } else { + first_root.join("demo") + }; + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("SKILL.md"), "owned by agent A").unwrap(); + let definitions = [ + ("task1b-vault-a", &first_root), + ("task1b-vault-b", &second_root), + ] + .map(|(id, root)| CustomAgentDef { + registry_id: id.into(), + name: id.into(), + description: String::new(), + version: "1.0.0".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "test-agent@1.0.0".into(), + ..Default::default() + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: Some(root.to_string_lossy().into_owned()), + source: Default::default(), + version_probe: None, + supports_mcp: true, + }); + assert!(hydrate(&definitions).is_empty()); + let agent = AgentType::custom(if enabled { + "task1b-vault-b" + } else { + "task1b-vault-a" + }) + .unwrap(); + let result = + tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_set_agent_skill_enabled( + agent, + AgentSkillScope::Global, + "demo".into(), + None, + enabled, + )); + hydrate(&[]); + assert!( + result.is_err(), + "shared vault must reject before mutation: {result:?}" + ); + assert!(result + .unwrap_err() + .to_string() + .contains("shared skill storage")); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + "owned by agent A" + ); + assert!(!second_root.join("demo").exists()); + assert_eq!(vault.exists(), enabled); + } + } + + #[test] + fn skill_enabled_private_safety_read_only_root_cannot_share_vault() { + use crate::acp::custom_registry::{ + hydrate, hydrate_test_guard, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, + NpxSpec, + }; + let _registry_guard = hydrate_test_guard(); + let tmp = tempfile::tempdir().unwrap(); + temp_env::with_var("GEMINI_HOME", Some(tmp.path()), || { + let cli_root = + crate::parsers::antigravity::resolve_antigravity_cli_dir().join("skills"); + let root = cli_root.parent().unwrap().join("custom-skills"); + fs::create_dir_all(&cli_root).unwrap(); + fs::create_dir_all(root.join("task1b-readonly-vault-demo")).unwrap(); + fs::write( + root.join("task1b-readonly-vault-demo/SKILL.md"), + "owned by custom agent", + ) + .unwrap(); + assert!(is_read_only_skill_path(AgentType::Antigravity, &cli_root)); + assert_eq!(disabled_skill_root(&root), disabled_skill_root(&cli_root)); + let definition = CustomAgentDef { + registry_id: "task1b-vault-cli".into(), + name: "CLI Vault Test".into(), + description: String::new(), + version: "1.0.0".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "test-agent@1.0.0".into(), + ..Default::default() + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: Some(root.to_string_lossy().into_owned()), + source: Default::default(), + version_probe: None, + supports_mcp: true, + }; + assert!(hydrate(&[definition]).is_empty()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled( + AgentType::custom("task1b-vault-cli").unwrap(), + AgentSkillScope::Global, + "task1b-readonly-vault-demo".into(), + None, + false, + )); + let listed = runtime + .block_on(acp_list_agent_skills(AgentType::Antigravity, None)) + .unwrap(); + hydrate(&[]); + assert!( + result.is_err(), + "read-only native root also scans its vault: {result:?}" + ); + assert!(result + .unwrap_err() + .to_string() + .contains("shared skill storage")); + assert_eq!( + fs::read_to_string(root.join("task1b-readonly-vault-demo/SKILL.md")).unwrap(), + "owned by custom agent" + ); + assert!(!disabled_skill_root(&root).exists()); + assert!(!listed + .skills + .iter() + .any(|item| item.id == "task1b-readonly-vault-demo")); + }); + } + #[test] fn skill_enabled_private_read_only_skill_cannot_be_toggled() { let tmp = tempfile::tempdir().expect("tempdir"); From 7c5480bc0bb7e9a881487d8450b89f498bb8e495 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:49:48 +0800 Subject: [PATCH 07/23] feat(skills): isolate shared skill toggles with transactional fan-out --- src-tauri/src/commands/acp.rs | 922 +++++++++++++++++++++++++++++++++- 1 file changed, 912 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 5d7c455ad2..4cba3d6a5a 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8900,6 +8900,485 @@ fn skill_roots_overlap(first: &Path, second: &Path) -> bool { first.starts_with(second) || second.starts_with(first) } +#[derive(Clone)] +struct SkillPeer { + agent: AgentType, + scope: AgentSkillScope, + kind: SkillStorageKind, + roots: Vec, +} + +fn skill_peers(workspace_path: Option<&str>) -> Vec { + let mut peers: Vec = Vec::new(); + for (agent, scope, root) in native_skill_roots(workspace_path) { + if let Some(peer) = peers + .iter_mut() + .find(|p| p.agent == agent && p.scope == scope) + { + peer.roots.push(root); + } else if let Some(spec) = skill_storage_spec(agent) { + peers.push(SkillPeer { + agent, + scope, + kind: spec.kind, + roots: vec![root], + }); + } + } + peers +} + +fn preflight_skill_destination(root: &Path, id: &str) -> Result<(), AcpError> { + // Reserve both layouts, including dangling links and malformed bundles. + for path in [root.join(id), root.join(format!("{id}.md"))] { + match fs::symlink_metadata(&path) { + Ok(_) => { + return Err(AcpError::protocol(format!( + "skill destination collision: '{}' already exists", + path.display() + ))) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(AcpError::protocol(format!( + "failed to inspect skill destination '{}': {e}", + path.display() + ))) + } + } + } + Ok(()) +} + +fn skill_root_writable(root: &Path) -> bool { + let mut ancestor = root; + loop { + match fs::metadata(ancestor) { + Ok(metadata) => { + if !metadata.is_dir() || metadata.permissions().readonly() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let Ok(path) = std::ffi::CString::new(ancestor.as_os_str().as_bytes()) else { + return false; + }; + // access checks search permission and ACLs without creating a probe file. + unsafe { + return libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) == 0; + } + } + #[cfg(not(unix))] + return true; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let Some(parent) = ancestor.parent() else { + return false; + }; + ancestor = parent; + } + Err(_) => return false, + } + } +} + +fn unique_skill_root(peer: &SkillPeer, peers: &[SkillPeer]) -> Result { + for root in &peer.roots { + let resolved = resolved_skill_root(root)?; + if is_read_only_skill_path(peer.agent, root) + || is_read_only_skill_path(peer.agent, &resolved) + || !skill_root_writable(root) + { + continue; + } + let mut unique = true; + for other in peers { + if other.agent == peer.agent && other.scope == peer.scope { + continue; + } + for other_root in &other.roots { + if skill_roots_overlap(&resolved, &resolved_skill_root(other_root)?) { + unique = false; + } + } + } + if unique { + return Ok(root.clone()); + } + } + Err(AcpError::protocol(format!( + "shared skill root: {} has no unique writable root", + peer.agent + ))) +} + +fn create_skill_link(source: &Path, destination: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + super::experts::create_link_raw(source, destination).map(|_| ()) + } + #[cfg(windows)] + { + if source.is_dir() { + // A copy fallback would stop following canonical edits. + junction::create(source, destination) + } else { + std::os::windows::fs::symlink_file(source, destination) + } + } +} + +fn remove_skill_link(path: &Path) -> std::io::Result<()> { + #[cfg(windows)] + if super::experts::path_is_reparse_point(path) && path.is_dir() { + return junction::delete(path); + } + fs::remove_file(path) +} + +fn skill_link_targets(path: &Path, canonical: &Path) -> bool { + let Some(target) = super::experts::read_link_target(path) else { + return false; + }; + let target = if target.is_absolute() { + target + } else { + path.parent().unwrap_or_else(|| Path::new("")).join(target) + }; + // Compare the link's direct destination, preserving a canonical entry that + // is itself a symlink. An independent link to the same content is not ours. + let identity = |entry: &Path| -> Option { + Some( + resolved_skill_root(entry.parent()?) + .ok()? + .join(entry.file_name()?), + ) + }; + identity(&target) + .zip(identity(canonical)) + .is_some_and(|(left, right)| left == right) +} + +fn delete_shared_skill( + canonical: &Path, + peers: &[SkillPeer], + id: &str, + mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>, +) -> Result<(), AcpError> { + let mut entries = vec![canonical.to_path_buf()]; + let mut seen = std::collections::HashSet::new(); + for peer in peers { + for root in &peer.roots { + for scan in [root.clone(), disabled_skill_root(root)] { + for path in [scan.join(id), scan.join(format!("{id}.md"))] { + if skill_link_targets(&path, canonical) { + let identity = resolved_skill_root(path.parent().expect("link parent"))? + .join(path.file_name().expect("link filename")); + if seen.insert(identity) { + entries.push(path); + } + } + } + } + } + } + let staging = canonical + .parent() + .ok_or_else(|| AcpError::protocol("canonical skill has no parent"))? + .join(format!(".codeg-delete-{}", uuid::Uuid::new_v4())); + fs::create_dir(&staging) + .map_err(|e| AcpError::protocol(format!("failed to stage skill deletion: {e}")))?; + let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new(); + for (index, entry) in entries.iter().enumerate() { + let destination = staging.join(index.to_string()); + if let Err(error) = rename(entry, &destination) { + let mut failures = Vec::new(); + for (source, staged) in moved.iter().rev() { + match fs::symlink_metadata(source) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Err(e) = fs::rename(staged, source) { + failures.push(e.to_string()); + } + } + _ => failures.push(format!( + "rollback destination '{}' is occupied or inaccessible", + source.display() + )), + } + } + let _ = fs::remove_dir(&staging); + return Err(AcpError::protocol(format!( + "shared skill deletion failed: {error}; {}", + if failures.is_empty() { + "rolled back".to_string() + } else { + format!( + "rollback failed: {}; recovery directory '{}'", + failures.join("; "), + staging.display() + ) + } + ))); + } + moved.push((entry.clone(), destination)); + } + // All visible entries are now gone: deletion is committed. Cleanup failure + // leaves only hidden recovery material, never dangling native scan entries. + for (_, staged) in &moved { + if let Err(error) = remove_skill_entry(staged) { + tracing::warn!(path = %staged.display(), %error, "shared skill deletion committed; recovery cleanup failed"); + } + } + if let Err(error) = fs::remove_dir(&staging) { + tracing::warn!(path = %staging.display(), %error, "shared skill deletion recovery directory retained"); + } + Ok(()) +} + +/// Preflight the complete peer plan before changing the canonical entry. +/// The caller holds SKILL_MUTATION_LOCK; link creation is injectable for IO failure tests. +fn set_shared_skill_enabled( + selected: &SkillPeer, + peers: &[SkillPeer], + root: &Path, + skill_id: &str, + enabled: bool, + mut create_link: impl FnMut(&Path, &Path) -> std::io::Result<()>, +) -> Result { + let id = validate_skill_id(skill_id)?; + let original = + locate_existing_skill_across_dirs(&selected.roots, selected.kind, &id, selected.scope) + .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; + reject_multiple_active_skills(&selected.roots, selected.kind, &id)?; + if original.enabled == enabled { + return Ok(original); + } + let resolved_root = resolved_skill_root(root)?; + let vault = disabled_skill_root(root); + let resolved_vault = resolved_skill_root(&vault)?; + for peer in peers { + for native in &peer.roots { + let resolved_native = resolved_skill_root(native)?; + if skill_roots_overlap(&resolved_vault, &resolved_native) { + return Err(AcpError::protocol( + "disabled skill vault overlaps native scan root", + )); + } + if resolved_native != resolved_root + && resolved_skill_root(&disabled_skill_root(native))? == resolved_vault + { + return Err(AcpError::protocol( + "shared skill storage: disabled vault has multiple native owners", + )); + } + } + } + let first_disable = original.enabled + && resolved_skill_root(Path::new(&original.path).parent().expect("skill parent"))? + == resolved_root; + let canonical_item = if first_disable { + original.clone() + } else { + locate_existing_skill(&vault, selected.kind, &id, selected.scope, false) + .ok_or_else(|| AcpError::protocol("shared canonical skill not found"))? + }; + let file_name = Path::new(&canonical_item.path) + .file_name() + .expect("skill filename"); + let canonical = resolved_vault.join(file_name); + let mut destinations = Vec::new(); + let mut affected = Vec::new(); + let mut restore_shared = false; + let mut redundant_links = Vec::new(); + if first_disable { + preflight_skill_destination(&vault, &id)?; + for scan in &selected.roots { + preflight_skill_destination(&disabled_skill_root(scan), &id)?; + } + preflight_skill_symlink_move(Path::new(&original.path), &vault)?; + for peer in peers { + let mut shares = false; + for scan in &peer.roots { + let resolved_scan = resolved_skill_root(scan)?; + if skill_roots_overlap(&resolved_root, &resolved_scan) { + if resolved_root != resolved_scan { + return Err(AcpError::protocol( + "shared skill root has overlapping scan roots that cannot be isolated", + )); + } + shares = true; + } + } + if !shares || (peer.agent == selected.agent && peer.scope == selected.scope) { + continue; + } + // A flat markdown file is invisible to directory-only consumers. + if canonical_item.layout == AgentSkillLayout::MarkdownFile + && peer.kind == SkillStorageKind::SkillDirectoryOnly + { + continue; + } + reject_multiple_active_skills(&peer.roots, peer.kind, &id)?; + for scan in &peer.roots { + preflight_skill_destination(&disabled_skill_root(scan), &id)?; + } + let destination_root = unique_skill_root(peer, peers)?; + preflight_skill_destination(&destination_root, &id)?; + destinations.push(destination_root.join(file_name)); + affected.push(peer); + } + } else if enabled { + match unique_skill_root(selected, peers) { + Ok(destination_root) => { + preflight_skill_destination(&destination_root, &id)?; + destinations.push(destination_root.join(file_name)); + } + Err(error) if error.to_string().contains("no unique writable root") => { + preflight_skill_destination(root, &id)?; + preflight_skill_symlink_move(&canonical, root)?; + for peer in peers { + if peer.agent == selected.agent && peer.scope == selected.scope { + continue; + } + let shares = peer + .roots + .iter() + .map(|scan| resolved_skill_root(scan)) + .collect::, _>>()? + .iter() + .any(|scan| skill_roots_overlap(scan, &resolved_root)); + if !shares + || (canonical_item.layout == AgentSkillLayout::MarkdownFile + && peer.kind == SkillStorageKind::SkillDirectoryOnly) + { + continue; + } + reject_multiple_active_skills(&peer.roots, peer.kind, &id)?; + let active = + locate_existing_skill_across_dirs(&peer.roots, peer.kind, &id, peer.scope) + .filter(|item| item.enabled) + .ok_or_else(|| { + AcpError::protocol( + "shared skill restore would reenable a disabled peer", + ) + })?; + if !skill_link_targets(Path::new(&active.path), &canonical) { + return Err(AcpError::protocol( + "shared skill restore would conflict with an independent peer skill", + )); + } + redundant_links.push(PathBuf::from(active.path)); + affected.push(peer); + } + restore_shared = true; + } + Err(error) => return Err(error), + } + } else if !skill_link_targets(Path::new(&original.path), &canonical) { + return Err(AcpError::protocol( + "active skill is not a managed canonical link", + )); + } + + let mut moved = false; + let mut created: Vec = Vec::new(); + let mut removed = false; + let mut removed_redundant = Vec::new(); + let restored_path = root.join(file_name); + let result = (|| { + if first_disable { + fs::create_dir_all(&vault)?; + fs::rename(&original.path, &canonical)?; + moved = true; + } + if restore_shared { + for link in &redundant_links { + remove_skill_link(link)?; + removed_redundant.push(link.clone()); + } + fs::rename(&canonical, &restored_path)?; + moved = true; + } + for destination in &destinations { + fs::create_dir_all(destination.parent().expect("destination parent"))?; + let linked = create_link(&canonical, destination); + if linked.is_ok() || skill_link_targets(destination, &canonical) { + created.push(destination.clone()); + } + linked?; + } + if !first_disable && !enabled { + remove_skill_link(Path::new(&original.path))?; + removed = true; + } + let item = list_skills_from_roots(selected.scope, &selected.roots, selected.kind) + .map_err(|e| std::io::Error::other(e.to_string()))? + .into_iter() + .find(|item| item.id == id && item.enabled == enabled) + .ok_or_else(|| std::io::Error::other("requested skill state was not reached"))?; + for peer in affected { + if !list_skills_from_roots(peer.scope, &peer.roots, peer.kind) + .map_err(|e| std::io::Error::other(e.to_string()))? + .iter() + .any(|item| item.id == id && item.enabled) + { + return Err(std::io::Error::other("peer skill state was not preserved")); + } + } + Ok(item) + })(); + match result { + Ok(mut item) => { + apply_skill_capabilities(selected.agent, &mut item); + Ok(item) + } + Err(error) => { + let mut failures = Vec::new(); + for destination in created.iter().rev() { + if !skill_link_targets(destination, &canonical) { + failures.push(format!( + "rollback refused for changed link '{}'", + destination.display() + )); + } else if let Err(e) = remove_skill_link(destination) { + failures.push(e.to_string()); + } + } + if moved { + let (from, to) = if restore_shared { + (restored_path.as_path(), canonical.as_path()) + } else { + (canonical.as_path(), Path::new(&original.path)) + }; + if fs::symlink_metadata(to).is_ok() { + failures.push("rollback source is occupied".to_string()); + } else if let Err(e) = fs::rename(from, to) { + failures.push(e.to_string()); + } + } + for link in removed_redundant { + if let Err(e) = create_skill_link(&canonical, &link) { + failures.push(e.to_string()); + } + } + if removed { + if let Err(e) = create_skill_link(&canonical, Path::new(&original.path)) { + failures.push(e.to_string()); + } + } + Err(AcpError::protocol(format!( + "shared skill toggle failed: {error}; {}", + if failures.is_empty() { + "rolled back".to_string() + } else { + format!("rollback failed: {}", failures.join("; ")) + } + ))) + } + } +} + fn skill_root_is_shared( agent_type: AgentType, scope: AgentSkillScope, @@ -13105,14 +13584,40 @@ pub async fn acp_set_agent_skill_enabled( "skill '{id}' is a built-in system skill and cannot be toggled" ))); } - if skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), root)? { - // Task 2 owns peer fan-out and per-agent isolation for shared roots. - return Err(AcpError::protocol(format!( - "shared skill root '{}' requires shared-root toggle support", - root.display() - ))); - } reject_multiple_active_skills(&dirs, spec.kind, &id)?; + for candidate in &dirs { + if !skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), candidate)? { + continue; + } + let canonical = locate_existing_skill( + &disabled_skill_root(candidate), + spec.kind, + &id, + scope, + false, + ); + if candidate == root + || canonical.is_some_and(|item| { + skill_link_targets(Path::new(&skill.path), Path::new(&item.path)) + }) + { + let peers = skill_peers(workspace_path.as_deref()); + let selected = SkillPeer { + agent: agent_type, + scope, + kind: spec.kind, + roots: dirs.clone(), + }; + return set_shared_skill_enabled( + &selected, + &peers, + candidate, + &id, + enabled, + create_skill_link, + ); + } + } if skill.enabled != enabled { preflight_disabled_skill_root(root, workspace_path.as_deref())?; } @@ -13242,6 +13747,23 @@ pub async fn acp_delete_agent_skill( ))); } let skill_path = PathBuf::from(&skill.path); + for root in &dirs { + if !skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), root)? { + continue; + } + if let Some(canonical) = + locate_existing_skill(&disabled_skill_root(root), spec.kind, &id, scope, false) + { + let canonical_path = Path::new(&canonical.path); + if skill_path == canonical_path || skill_link_targets(&skill_path, canonical_path) { + let peers = skill_peers(workspace_path.as_deref()); + preflight_disabled_skill_root(root, workspace_path.as_deref())?; + return delete_shared_skill(canonical_path, &peers, &id, |from, to| { + fs::rename(from, to) + }); + } + } + } remove_skill_entry(&skill_path) .map_err(|e| AcpError::protocol(format!("failed to delete skill entry: {e}")))?; Ok(()) @@ -16365,15 +16887,395 @@ wire_api = "chat" ); } + fn shared_skill_fixture(base: &Path) -> (PathBuf, Vec) { + let shared = base.join("shared/skills"); + fs::create_dir_all(shared.join("demo")).unwrap(); + fs::write(shared.join("demo/SKILL.md"), "shared").unwrap(); + let peers = [(AgentType::Codex, "a"), (AgentType::Pi, "b")] + .into_iter() + .map(|(agent, name)| SkillPeer { + agent, + scope: AgentSkillScope::Project, + kind: SkillStorageKind::SkillDirectoryOrMarkdownFile, + roots: vec![base.join(name).join("skills"), shared.clone()], + }) + .collect(); + (shared, peers) + } + #[test] - fn skill_enabled_private_command_leaves_shared_roots_for_task_two() { - let tmp = tempfile::tempdir().expect("tempdir"); + fn shared_skill_fanout_isolates_and_reenables_selected_peer() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + for enabled in [false, true, false] { + let item = set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "demo", + enabled, + create_skill_link, + ) + .unwrap(); + assert_eq!(item.enabled, enabled); + assert!(peers[0].roots[0].join("demo/SKILL.md").is_file()); + assert_eq!(peers[1].roots[0].join("demo").exists(), enabled); + assert!(disabled_skill_root(&shared).join("demo/SKILL.md").is_file()); + assert!(!shared.join("demo").exists()); + for (peer, expected) in [(&peers[0], true), (&peers[1], enabled)] { + let items = list_skills_from_roots(peer.scope, &peer.roots, peer.kind).unwrap(); + assert_eq!(items[0].enabled, expected); + } + } + } + + #[test] + fn shared_skill_peer_without_unique_root_refuses_before_move() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + peers[0].roots = vec![shared.clone()]; + let error = + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) + .unwrap_err(); + assert!(error.to_string().contains("unique writable root")); + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).exists()); + } + + #[test] + fn shared_skill_selected_without_unique_root_can_restore_when_peers_enabled() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + peers[1].roots = vec![shared.clone()]; + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) + .unwrap(); + assert!(peers[0].roots[0].join("demo/SKILL.md").is_file()); + let item = + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", true, create_skill_link) + .unwrap(); + assert!(item.enabled); + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).join("demo").exists()); + assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); + } + + #[test] + fn shared_skill_restore_refuses_to_reenable_a_disabled_peer() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + peers[1].roots = vec![shared.clone()]; + for peer in [&peers[1], &peers[0]] { + set_shared_skill_enabled(peer, &peers, &shared, "demo", false, create_skill_link) + .unwrap(); + } + let error = + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", true, create_skill_link) + .unwrap_err(); + assert!(error.to_string().contains("disabled peer")); + assert!(!shared.join("demo").exists()); + assert!(disabled_skill_root(&shared).join("demo/SKILL.md").is_file()); + assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); + } + + #[test] + fn shared_skill_command_claude_cline_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".claude/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "body").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + for enabled in [false, true] { + let item = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::ClaudeCode, + AgentSkillScope::Project, + "demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + enabled, + )) + .unwrap(); + assert_eq!(item.enabled, enabled); + let cline = scoped_skill_dirs( + AgentType::Cline, + AgentSkillScope::Project, + tmp.path().to_str(), + ) + .unwrap(); + assert!( + list_skills_from_roots( + AgentSkillScope::Project, + &cline, + SkillStorageKind::SkillDirectoryOnly + ) + .unwrap()[0] + .enabled + ); + } + } + + #[cfg(unix)] + #[test] + fn shared_skill_unsearchable_root_is_rejected_before_move() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + fs::create_dir_all(&peers[0].roots[0]).unwrap(); + fs::set_permissions(&peers[0].roots[0], fs::Permissions::from_mode(0o600)).unwrap(); + let result = + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link); + fs::set_permissions(&peers[0].roots[0], fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result + .unwrap_err() + .to_string() + .contains("unique writable root")); + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).exists()); + } + + #[test] + fn shared_skill_destination_collision_refuses_before_move() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + fs::create_dir_all(&peers[0].roots[0]).unwrap(); + fs::write(peers[0].roots[0].join("demo"), "occupied").unwrap(); + let error = + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) + .unwrap_err(); + assert!(error.to_string().contains("collision")); + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).exists()); + } + + #[test] + fn shared_skill_disabled_copy_collision_refuses_before_move() { + for conflicting_peer in [0, 1] { + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + let other_vault = disabled_skill_root(&peers[conflicting_peer].roots[0]); + fs::create_dir_all(other_vault.join("demo")).unwrap(); + fs::write( + other_vault.join("demo/SKILL.md"), + "different disabled skill", + ) + .unwrap(); + let error = set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "demo", + false, + create_skill_link, + ) + .unwrap_err(); + assert!(error.to_string().contains("collision")); + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).exists()); + } + } + + #[test] + fn shared_skill_partial_link_failure_rolls_back_link_and_source() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + let error = set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "demo", + false, + |source, target| { + create_skill_link(source, target)?; + Err(std::io::Error::other("failure after creating link")) + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("failure after creating link")); + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); + } + + #[test] + fn shared_skill_link_failure_rolls_back_source_and_created_links() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + peers.push(SkillPeer { + agent: AgentType::OpenCode, + roots: vec![tmp.path().join("c/skills"), shared.clone()], + ..peers[0].clone() + }); + let mut calls = 0; + let error = set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "demo", + false, + |source, target| { + calls += 1; + if calls == 2 { + return Err(std::io::Error::other("injected link failure")); + } + create_skill_link(source, target) + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("injected link failure")); + assert_eq!(calls, 2); + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).join("demo").exists()); + for peer in peers { + assert!(fs::symlink_metadata(peer.roots[0].join("demo")).is_err()); + } + } + + #[test] + fn shared_skill_markdown_file_keeps_canonical_content() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + fs::write(shared.join("flat.md"), "flat content").unwrap(); + for enabled in [false, true] { + set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "flat", + enabled, + create_skill_link, + ) + .unwrap(); + assert_eq!( + fs::read_to_string(peers[0].roots[0].join("flat.md")).unwrap(), + "flat content" + ); + assert_eq!(peers[1].roots[0].join("flat.md").exists(), enabled); + } + } + + #[test] + fn shared_skill_delete_canonical_removes_peer_links() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) + .unwrap(); + let canonical = disabled_skill_root(&shared).join("demo"); + delete_shared_skill(&canonical, &peers, "demo", |from, to| fs::rename(from, to)).unwrap(); + assert!(!canonical.exists()); + assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); + for peer in &peers { + assert!(list_skills_from_roots(peer.scope, &peer.roots, peer.kind) + .unwrap() + .is_empty()); + } + } + + #[test] + fn shared_skill_delete_rename_failure_rolls_back_canonical_and_links() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + peers.push(SkillPeer { + agent: AgentType::OpenCode, + roots: vec![tmp.path().join("c/skills"), shared.clone()], + ..peers[0].clone() + }); + set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) + .unwrap(); + let canonical = disabled_skill_root(&shared).join("demo"); + let mut calls = 0; + let result = delete_shared_skill(&canonical, &peers, "demo", |from, to| { + calls += 1; + if calls == 3 { + return Err(std::io::Error::other("injected delete failure")); + } + fs::rename(from, to) + }); + assert!(result + .unwrap_err() + .to_string() + .contains("injected delete failure")); + assert_eq!(calls, 3); + assert!(canonical.join("SKILL.md").is_file()); + for peer in [&peers[0], &peers[2]] { + assert!(peer.roots[0].join("demo/SKILL.md").is_file()); + } + } + + #[cfg(unix)] + #[test] + fn shared_skill_delete_keeps_independent_links_to_external_content() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + let external = tmp.path().join("external"); + fs::create_dir_all(&external).unwrap(); + fs::write(external.join("SKILL.md"), "external").unwrap(); + std::os::unix::fs::symlink(&external, shared.join("linked")).unwrap(); + set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "linked", + false, + create_skill_link, + ) + .unwrap(); + let independent = tmp.path().join("independent/skills"); + fs::create_dir_all(&independent).unwrap(); + std::os::unix::fs::symlink(&external, independent.join("linked")).unwrap(); + peers.push(SkillPeer { + agent: AgentType::OpenCode, + roots: vec![independent.clone()], + ..peers[0].clone() + }); + delete_shared_skill( + &disabled_skill_root(&shared).join("linked"), + &peers, + "linked", + |from, to| fs::rename(from, to), + ) + .unwrap(); + assert!(independent.join("linked/SKILL.md").is_file()); + assert!(external.join("SKILL.md").is_file()); + assert!(fs::symlink_metadata(peers[0].roots[0].join("linked")).is_err()); + } + + #[test] + fn shared_skill_command_delete_canonical_removes_cline_link() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".claude/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "body").unwrap(); + let workspace = Some(tmp.path().to_string_lossy().into_owned()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::ClaudeCode, + AgentSkillScope::Project, + "demo".into(), + workspace.clone(), + false, + )) + .unwrap(); + let link = tmp.path().join(".cline/skills/demo"); + assert!(link.join("SKILL.md").is_file()); + runtime + .block_on(acp_delete_agent_skill( + AgentType::ClaudeCode, + AgentSkillScope::Project, + "demo".into(), + workspace, + )) + .unwrap(); + assert!(fs::symlink_metadata(link).is_err()); + assert!(!disabled_skill_root(&root).join("demo").exists()); + } + + #[test] + fn skill_enabled_private_command_unisolatable_shared_roots_are_rejected() { let runtime = tokio::runtime::Runtime::new().unwrap(); for (agent, relative) in [ (AgentType::Codex, ".agents/skills"), - (AgentType::ClaudeCode, ".claude/skills"), (AgentType::Gemini, ".gemini/skills"), ] { + let tmp = tempfile::tempdir().expect("tempdir"); let root = tmp.path().join(relative); fs::create_dir_all(root.join("demo")).unwrap(); fs::write(root.join("demo/SKILL.md"), "shared").unwrap(); From a6fd56e9294224598f8f1346ef115f0087b58d33 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:08:11 +0800 Subject: [PATCH 08/23] fix(skills): preserve shared ownership across aliases and platforms --- src-tauri/src/commands/acp.rs | 226 ++++++++++++++++++++++++++++-- src-tauri/src/commands/experts.rs | 4 +- 2 files changed, 214 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 4cba3d6a5a..163d886445 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -9013,6 +9013,27 @@ fn unique_skill_root(peer: &SkillPeer, peers: &[SkillPeer]) -> Result Result<(), AcpError> { + let resolved = resolved_skill_root(root)?; + for peer in peers { + for native in &peer.roots { + let resolved_native = resolved_skill_root(native)?; + if let Ok(relative) = resolved.strip_prefix(&resolved_native) { + // Evaluate the owner's lexical path so aliases cannot erase + // that owner's builtin-directory policy. + if is_read_only_skill_path(peer.agent, &native.join(relative)) { + return Err(AcpError::protocol(format!( + "shared skill root '{}' is read-only for owning agent {}", + root.display(), + peer.agent + ))); + } + } + } + } + Ok(()) +} + fn create_skill_link(source: &Path, destination: &Path) -> std::io::Result<()> { #[cfg(unix)] { @@ -9154,6 +9175,7 @@ fn set_shared_skill_enabled( if original.enabled == enabled { return Ok(original); } + preflight_shared_skill_owner(root, peers)?; let resolved_root = resolved_skill_root(root)?; let vault = disabled_skill_root(root); let resolved_vault = resolved_skill_root(&vault)?; @@ -9430,7 +9452,11 @@ fn reject_multiple_active_skills( skill_id: &str, ) -> Result<(), AcpError> { let mut matches = 0; + let mut seen_roots = std::collections::HashSet::new(); for root in roots { + if !seen_roots.insert(resolved_skill_root(root)?) { + continue; + } let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, @@ -13747,20 +13773,29 @@ pub async fn acp_delete_agent_skill( ))); } let skill_path = PathBuf::from(&skill.path); - for root in &dirs { - if !skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), root)? { - continue; - } - if let Some(canonical) = - locate_existing_skill(&disabled_skill_root(root), spec.kind, &id, scope, false) - { - let canonical_path = Path::new(&canonical.path); - if skill_path == canonical_path || skill_link_targets(&skill_path, canonical_path) { - let peers = skill_peers(workspace_path.as_deref()); - preflight_disabled_skill_root(root, workspace_path.as_deref())?; - return delete_shared_skill(canonical_path, &peers, &id, |from, to| { - fs::rename(from, to) - }); + let peers = skill_peers(workspace_path.as_deref()); + // A shared root alias can own a vault outside this agent's lexical roots. + // Only a direct link to a known peer vault entry establishes ownership. + for peer in &peers { + for root in &peer.roots { + if !skill_root_is_shared(peer.agent, peer.scope, workspace_path.as_deref(), root)? { + continue; + } + if let Some(canonical) = locate_existing_skill( + &disabled_skill_root(root), + peer.kind, + &id, + peer.scope, + false, + ) { + let canonical_path = Path::new(&canonical.path); + if skill_path == canonical_path || skill_link_targets(&skill_path, canonical_path) { + preflight_shared_skill_owner(root, &peers)?; + preflight_disabled_skill_root(root, workspace_path.as_deref())?; + return delete_shared_skill(canonical_path, &peers, &id, |from, to| { + fs::rename(from, to) + }); + } } } } @@ -16903,6 +16938,169 @@ wire_api = "chat" (shared, peers) } + fn shared_skill_custom_def( + id: &str, + root: &Path, + ) -> crate::acp::custom_registry::CustomAgentDef { + use crate::acp::custom_registry::{ + CustomAgentDef, CustomAgentSpec, CustomDistributionKind, NpxSpec, + }; + CustomAgentDef { + registry_id: id.into(), + name: id.into(), + description: String::new(), + version: "1.0.0".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "test-agent@1.0.0".into(), + ..Default::default() + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: Some(root.to_string_lossy().into_owned()), + source: Default::default(), + version_probe: None, + supports_mcp: true, + } + } + + #[test] + fn shared_skill_reparse_probe_is_accessible_from_acp() { + let tmp = tempfile::tempdir().unwrap(); + assert!(!crate::commands::experts::path_is_reparse_point(tmp.path())); + } + + #[test] + fn shared_skill_custom_owner_cannot_disable_another_agents_builtin_root() { + use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; + let _registry_guard = hydrate_test_guard(); + let tmp = tempfile::tempdir().unwrap(); + temp_env::with_var("GEMINI_HOME", Some(tmp.path()), || { + let root = tmp.path().join("antigravity-cli/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "builtin").unwrap(); + assert!(hydrate(&[shared_skill_custom_def("task2-readonly-owner", &root)]).is_empty()); + let result = + tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_set_agent_skill_enabled( + AgentType::custom("task2-readonly-owner").unwrap(), + AgentSkillScope::Global, + "demo".into(), + None, + false, + )); + hydrate(&[]); + assert!(result.unwrap_err().to_string().contains("read-only")); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "builtin" + ); + assert!(!disabled_skill_root(&root).exists()); + }); + } + + #[cfg(unix)] + #[test] + fn shared_skill_alias_roots_do_not_duplicate_followup_toggles() { + let tmp = tempfile::tempdir().unwrap(); + let shared = tmp.path().join(".claude/skills"); + let cline = tmp.path().join(".cline/skills"); + fs::create_dir_all(shared.join("demo")).unwrap(); + fs::write(shared.join("demo/SKILL.md"), "body").unwrap(); + fs::create_dir_all(&cline).unwrap(); + fs::create_dir_all(tmp.path().join(".clinerules")).unwrap(); + std::os::unix::fs::symlink(&cline, tmp.path().join(".clinerules/skills")).unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + for (agent, enabled) in [ + (AgentType::ClaudeCode, false), + (AgentType::Cline, false), + (AgentType::Cline, true), + (AgentType::ClaudeCode, true), + ] { + let item = runtime + .block_on(acp_set_agent_skill_enabled( + agent, + AgentSkillScope::Project, + "demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + enabled, + )) + .unwrap(); + assert_eq!(item.enabled, enabled); + } + assert!(shared.join("demo/SKILL.md").is_file()); + assert!(fs::symlink_metadata(cline.join("demo")).is_err()); + } + + #[cfg(unix)] + #[test] + fn shared_skill_delete_via_alias_canonical_link_is_global() { + use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; + let _registry_guard = hydrate_test_guard(); + let tmp = tempfile::tempdir().unwrap(); + temp_env::with_vars( + [ + ("HOME", Some(tmp.path())), + ("CODEX_HOME", None::<&Path>), + ("GEMINI_HOME", None), + ("PI_CODING_AGENT_DIR", None), + ("DSH_HOME", None), + ("DSH_AGENTS_HOME", None), + ("QODER_CONFIG_DIR", None), + ("QODER_CLI_HOME", None), + ], + || { + let shared = tmp.path().join(".agents/skills"); + let alias = tmp.path().join("custom/skills"); + fs::create_dir_all(shared.join("demo")).unwrap(); + fs::write(shared.join("demo/SKILL.md"), "body").unwrap(); + fs::create_dir_all(alias.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&shared, &alias).unwrap(); + assert!( + hydrate(&[shared_skill_custom_def("task2-alias-owner", &alias)]).is_empty() + ); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let result = runtime.block_on(async { + acp_set_agent_skill_enabled( + AgentType::custom("task2-alias-owner").unwrap(), + AgentSkillScope::Global, + "demo".into(), + None, + false, + ) + .await?; + acp_delete_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + "demo".into(), + None, + ) + .await + }); + let peers = skill_peers(None); + hydrate(&[]); + result.unwrap(); + assert!( + !disabled_skill_root(&alias).join("demo").exists(), + "canonical installation remains" + ); + for peer in peers { + for root in peer.roots { + assert!( + fs::symlink_metadata(root.join("demo")).is_err(), + "leftover peer entry: {}", + root.display() + ); + } + } + }, + ); + } + #[test] fn shared_skill_fanout_isolates_and_reenables_selected_peer() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/commands/experts.rs b/src-tauri/src/commands/experts.rs index fcd5871354..5cddc1de82 100644 --- a/src-tauri/src/commands/experts.rs +++ b/src-tauri/src/commands/experts.rs @@ -467,7 +467,7 @@ pub(crate) fn path_is_symlink(path: &Path) -> bool { /// point. `symlink_metadata` reports it as a directory. So we also need to /// ask the OS whether the directory is a reparse point. #[cfg(windows)] -fn path_is_reparse_point(path: &Path) -> bool { +pub(crate) fn path_is_reparse_point(path: &Path) -> bool { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; fs::symlink_metadata(path) @@ -476,7 +476,7 @@ fn path_is_reparse_point(path: &Path) -> bool { } #[cfg(not(windows))] -fn path_is_reparse_point(_path: &Path) -> bool { +pub(crate) fn path_is_reparse_point(_path: &Path) -> bool { false } From 136e1eacf747f63a97c9c2bcf257999dcd3d6322 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:45:41 +0800 Subject: [PATCH 09/23] feat(skills): expose skill toggles over transports --- src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/acp.rs | 29 ++++++- src-tauri/src/web/router.rs | 4 + src-tauri/tests/api_integration.rs | 17 ++++ .../tasks/task-message-composer.test.tsx | 2 + src/lib/api-agent-skill-toggle.test.ts | 85 +++++++++++++++++++ src/lib/api.ts | 16 ++++ src/lib/tauri.ts | 16 ++++ src/lib/types.ts | 2 + 9 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/lib/api-agent-skill-toggle.test.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2b692404f5..0582f8465e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1477,6 +1477,7 @@ mod tauri_app { crate::commands::custom_agents::acp_add_registry_agent, crate::commands::custom_agents::acp_current_platform, acp_commands::acp_list_agent_skills, + acp_commands::acp_set_agent_skill_enabled, acp_commands::acp_read_agent_skill, acp_commands::acp_save_agent_skill, acp_commands::acp_delete_agent_skill, diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 756daa7937..555af15543 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -8,8 +8,8 @@ use crate::acp::error::AcpError; use crate::acp::opencode_plugins::PluginCheckSummary; use crate::acp::preflight::PreflightResult; use crate::acp::types::{ - AcpAgentInfo, AcpAgentStatus, AgentDiagnosticsReport, AgentSkillContent, AgentSkillLayout, - AgentSkillScope, AgentSkillsListResult, ConnectionInfo, ForkResultInfo, + AcpAgentInfo, AcpAgentStatus, AgentDiagnosticsReport, AgentSkillContent, AgentSkillItem, + AgentSkillLayout, AgentSkillScope, AgentSkillsListResult, ConnectionInfo, ForkResultInfo, }; use crate::app_error::{AppCommandError, AppErrorCode}; use crate::app_state::AppState; @@ -232,6 +232,31 @@ pub async fn acp_list_agent_skills( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSetAgentSkillEnabledParams { + pub agent_type: AgentType, + pub scope: AgentSkillScope, + pub skill_id: String, + pub workspace_path: Option, + pub enabled: bool, +} + +pub async fn acp_set_agent_skill_enabled( + Json(params): Json, +) -> Result, AppCommandError> { + let result = acp_commands::acp_set_agent_skill_enabled( + params.agent_type, + params.scope, + params.skill_id, + params.workspace_path, + params.enabled, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpReadAgentSkillParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index aaed65db10..91bfefbff1 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -945,6 +945,10 @@ pub fn build_router( "/acp_list_agent_skills", post(handlers::acp::acp_list_agent_skills), ) + .route( + "/acp_set_agent_skill_enabled", + post(handlers::acp::acp_set_agent_skill_enabled), + ) .route( "/acp_read_agent_skill", post(handlers::acp::acp_read_agent_skill), diff --git a/src-tauri/tests/api_integration.rs b/src-tauri/tests/api_integration.rs index 4c7104fe90..c3db4d5152 100644 --- a/src-tauri/tests/api_integration.rs +++ b/src-tauri/tests/api_integration.rs @@ -202,6 +202,23 @@ async fn unknown_endpoint_returns_501_with_typed_error() { assert!(body["message"].is_string()); } +#[tokio::test] +async fn agent_skill_toggle_route_rejects_snake_case_params() { + let (server, _data, _static) = build_test_server().await; + let resp = server + .post("/api/acp_set_agent_skill_enabled") + .add_header("authorization", format!("Bearer {TEST_TOKEN}")) + .json(&json!({ + "agent_type": "codex", + "scope": "project", + "skill_id": "example", + "workspace_path": null, + "enabled": false + })) + .await; + assert_eq!(resp.status_code(), 422); +} + // ──────────────────────────────────────────────────────────────────────────── // Live feedback settings + submit gate // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/components/tasks/task-message-composer.test.tsx b/src/components/tasks/task-message-composer.test.tsx index a904935cc9..d54d2bf109 100644 --- a/src/components/tasks/task-message-composer.test.tsx +++ b/src/components/tasks/task-message-composer.test.tsx @@ -38,6 +38,8 @@ const SKILL: AgentSkillItem = { path: "/skills/deploy", description: "Ship it", read_only: false, + enabled: true, + can_toggle: true, } // The "+" menu's data sources all hit the transport; none of them is what // these tests exercise. diff --git a/src/lib/api-agent-skill-toggle.test.ts b/src/lib/api-agent-skill-toggle.test.ts new file mode 100644 index 0000000000..12c617bbd8 --- /dev/null +++ b/src/lib/api-agent-skill-toggle.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + invoke: vi.fn(), +})) + +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ call: mocks.call }), + getShellTransport: () => ({ call: vi.fn() }), + isDesktop: () => false, + isRemoteDesktopMode: () => false, + getActiveRemoteConnectionId: () => null, + notifyRemoteDesktopUnauthorized: vi.fn(), +})) + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, +})) + +import { acpSetAgentSkillEnabled as callAgentSkillToggle } from "@/lib/api" +import { acpSetAgentSkillEnabled as invokeAgentSkillToggle } from "@/lib/tauri" +import type { AgentSkillItem } from "@/lib/types" + +const TOGGLE_RESULT: AgentSkillItem = { + id: "example", + name: "Example", + scope: "project", + layout: "skill_directory", + path: "/workspace/.agents/skills/example", + description: null, + read_only: false, + enabled: false, + can_toggle: true, +} + +describe("acpSetAgentSkillEnabled", () => { + beforeEach(() => { + mocks.call.mockReset() + mocks.invoke.mockReset() + }) + + it("sends camelCase params through the shared transport", async () => { + mocks.call.mockResolvedValue(TOGGLE_RESULT) + + await expect( + callAgentSkillToggle({ + agentType: "codex", + scope: "project", + skillId: "example", + enabled: false, + }) + ).resolves.toBe(TOGGLE_RESULT) + + expect(mocks.call).toHaveBeenCalledWith("acp_set_agent_skill_enabled", { + agentType: "codex", + scope: "project", + skillId: "example", + workspacePath: null, + enabled: false, + }) + }) + + it("uses the same command contract for desktop invoke", async () => { + mocks.invoke.mockResolvedValue(TOGGLE_RESULT) + + await expect( + invokeAgentSkillToggle({ + agentType: "claude", + scope: "global", + skillId: "example", + workspacePath: "/workspace", + enabled: true, + }) + ).resolves.toBe(TOGGLE_RESULT) + + expect(mocks.invoke).toHaveBeenCalledWith("acp_set_agent_skill_enabled", { + agentType: "claude", + scope: "global", + skillId: "example", + workspacePath: "/workspace", + enabled: true, + }) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index e9b0d030e0..5e80cec1fb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1363,6 +1363,22 @@ export async function acpListAgentSkills(params: { }) } +export async function acpSetAgentSkillEnabled(params: { + agentType: AgentType + scope: AgentSkillScope + skillId: string + workspacePath?: string | null + enabled: boolean +}): Promise { + return getTransport().call("acp_set_agent_skill_enabled", { + agentType: params.agentType, + scope: params.scope, + skillId: params.skillId, + workspacePath: params.workspacePath ?? null, + enabled: params.enabled, + }) +} + export async function acpReadAgentSkill(params: { agentType: AgentType scope: AgentSkillScope diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index d187eb0d2c..9edb2a3b5e 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -281,6 +281,22 @@ export async function acpListAgentSkills(params: { }) } +export async function acpSetAgentSkillEnabled(params: { + agentType: AgentType + scope: AgentSkillScope + skillId: string + workspacePath?: string | null + enabled: boolean +}): Promise { + return invoke("acp_set_agent_skill_enabled", { + agentType: params.agentType, + scope: params.scope, + skillId: params.skillId, + workspacePath: params.workspacePath ?? null, + enabled: params.enabled, + }) +} + export async function acpReadAgentSkill(params: { agentType: AgentType scope: AgentSkillScope diff --git a/src/lib/types.ts b/src/lib/types.ts index 0685d7382a..f0d97feb94 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3486,6 +3486,8 @@ export interface AgentSkillItem { path: string description: string | null read_only: boolean + enabled: boolean + can_toggle: boolean } export interface AgentSkillsListResult { From 119f86ab5dc2051a94798fbaacccf10f066887a4 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:15:45 +0800 Subject: [PATCH 10/23] feat(skills): add availability controls --- .../settings/skills-settings.test.tsx | 201 ++++++++++++++++++ src/components/settings/skills-settings.tsx | 143 ++++++++++--- src/hooks/use-agent-skills.test.tsx | 53 +++++ src/hooks/use-agent-skills.ts | 4 +- 4 files changed, 370 insertions(+), 31 deletions(-) create mode 100644 src/components/settings/skills-settings.test.tsx create mode 100644 src/hooks/use-agent-skills.test.tsx diff --git a/src/components/settings/skills-settings.test.tsx b/src/components/settings/skills-settings.test.tsx new file mode 100644 index 0000000000..dc3764cd6e --- /dev/null +++ b/src/components/settings/skills-settings.test.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import type { + AcpAgentInfo, + AgentSkillItem, + AgentSkillsListResult, +} from "@/lib/types" + +const api = vi.hoisted(() => ({ + acpDeleteAgentSkill: vi.fn(), + acpListAgents: vi.fn(), + acpListAgentSkills: vi.fn(), + acpReadAgentSkill: vi.fn(), + acpSaveAgentSkill: vi.fn(), + acpSetAgentSkillEnabled: vi.fn(), + loadFolderHistory: vi.fn(), + openFolder: vi.fn(), +})) + +const toast = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), +})) + +vi.mock("@/lib/api", () => api) +vi.mock("sonner", () => ({ toast })) + +import { SkillsSettings } from "./skills-settings" + +const messages = { + ...enMessages, + SkillsSettings: { + ...enMessages.SkillsSettings, + availability: { + enabled: "Enabled", + disabled: "Disabled", + toggleAria: "Toggle {skill} for {agent}", + readOnly: "Built-in skills are always available.", + cannotIsolate: "This shared skill cannot be toggled independently.", + }, + toasts: { + ...enMessages.SkillsSettings.toasts, + enabled: "Skill enabled", + disabled: "Skill disabled", + toggleFailed: "Failed to update skill availability", + }, + }, +} + +const agent = { + agent_type: "codex", + name: "Codex", + sort_order: 0, +} as AcpAgentInfo + +function skill(overrides: Partial = {}): AgentSkillItem { + return { + id: "demo", + name: "Demo Skill", + scope: "global", + layout: "skill_directory", + path: "/home/test/.codex/skills/demo/SKILL.md", + description: "Demo", + read_only: false, + enabled: true, + can_toggle: true, + ...overrides, + } +} + +function listResult(item: AgentSkillItem): AgentSkillsListResult { + return { + supported: true, + message: null, + locations: [ + { + scope: "global", + path: "/home/test/.codex/skills", + exists: true, + }, + ], + skills: [item], + } +} + +function renderSettings() { + return render( + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + api.acpListAgents.mockResolvedValue([agent]) + api.acpListAgentSkills.mockResolvedValue(listResult(skill())) + api.acpReadAgentSkill.mockResolvedValue({ + skill: skill(), + content: "# Demo", + }) + api.acpSaveAgentSkill.mockResolvedValue(undefined) + api.acpDeleteAgentSkill.mockResolvedValue(undefined) + api.acpSetAgentSkillEnabled.mockResolvedValue(skill({ enabled: false })) + api.loadFolderHistory.mockResolvedValue([]) + api.openFolder.mockResolvedValue(undefined) +}) + +describe("SkillsSettings availability", () => { + it("toggles a skill without opening its row and reloads the authoritative list", async () => { + api.acpListAgentSkills + .mockResolvedValueOnce(listResult(skill())) + .mockResolvedValueOnce(listResult(skill())) + .mockResolvedValueOnce(listResult(skill({ enabled: false }))) + + renderSettings() + + const availability = await screen.findByRole("switch", { + name: "Toggle Demo Skill for Codex", + }) + expect(availability).toBeChecked() + await waitFor(() => expect(api.acpReadAgentSkill).toHaveBeenCalledTimes(1)) + + fireEvent.click(availability) + + await waitFor(() => + expect(api.acpSetAgentSkillEnabled).toHaveBeenCalledWith({ + agentType: "codex", + scope: "global", + skillId: "demo", + workspacePath: null, + enabled: false, + }) + ) + await waitFor(() => expect(api.acpListAgentSkills).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(availability).not.toBeChecked()) + expect(api.acpReadAgentSkill).toHaveBeenCalledTimes(1) + expect(toast.success).toHaveBeenCalledWith("Skill disabled") + }) + + it("disables non-toggleable skills and exposes an unavailable hint", async () => { + api.acpListAgentSkills.mockResolvedValue( + listResult(skill({ read_only: true, can_toggle: false })) + ) + + renderSettings() + + const availability = await screen.findByRole("switch", { + name: "Toggle Demo Skill for Codex", + }) + expect(availability).toBeDisabled() + expect(availability).toHaveAttribute( + "title", + "Built-in skills are always available." + ) + }) + + it("explains when a shared skill cannot be toggled independently", async () => { + api.acpListAgentSkills.mockResolvedValue( + listResult(skill({ read_only: false, can_toggle: false })) + ) + + renderSettings() + + const availability = await screen.findByRole("switch", { + name: "Toggle Demo Skill for Codex", + }) + expect(availability).toBeDisabled() + expect(availability).toHaveAttribute( + "title", + "This shared skill cannot be toggled independently." + ) + }) + + it("reloads authoritative state and reports a localized error after failure", async () => { + api.acpSetAgentSkillEnabled.mockRejectedValue( + new Error("permission denied") + ) + api.acpListAgentSkills + .mockResolvedValueOnce(listResult(skill())) + .mockResolvedValueOnce(listResult(skill())) + .mockResolvedValueOnce(listResult(skill())) + + renderSettings() + + const availability = await screen.findByRole("switch", { + name: "Toggle Demo Skill for Codex", + }) + fireEvent.click(availability) + + await waitFor(() => expect(api.acpListAgentSkills).toHaveBeenCalledTimes(3)) + expect(availability).toBeChecked() + expect(toast.error).toHaveBeenCalledWith( + "Failed to update skill availability", + { description: "permission denied" } + ) + }) +}) diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index 0423351186..a89aab912e 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -46,6 +46,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" import { parseYamlFrontMatter } from "@/lib/skill-frontmatter" @@ -53,6 +54,7 @@ import { acpDeleteAgentSkill, acpListAgents, acpListAgentSkills, + acpSetAgentSkillEnabled, loadFolderHistory, openFolder, acpReadAgentSkill, @@ -206,6 +208,7 @@ export function SkillsSettings() { const [skillReading, setSkillReading] = useState(false) const [skillSaving, setSkillSaving] = useState(false) const [skillDeletingId, setSkillDeletingId] = useState(null) + const [skillTogglingId, setSkillTogglingId] = useState(null) const [deleteTargetSkill, setDeleteTargetSkill] = useState(null) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) @@ -412,6 +415,42 @@ export function SkillsSettings() { } }, []) + const handleToggleSkill = useCallback( + async (skill: AgentSkillItem) => { + if (!selectedAgent || !skill.can_toggle || skillTogglingId) return + + const enabled = !skill.enabled + setSkillTogglingId(skill.id) + + try { + await acpSetAgentSkillEnabled({ + agentType: selectedAgent.agent_type, + scope: skill.scope, + skillId: skill.id, + workspacePath: + skill.scope === "project" ? workspacePathForRequest : null, + enabled, + }) + toast.success(skillsT(enabled ? "toasts.enabled" : "toasts.disabled")) + } catch (err) { + toast.error(skillsT("toasts.toggleFailed"), { + description: toErrorMessage(err), + }) + } finally { + invalidateAgentSkillsCache(selectedAgent.agent_type) + await loadSkills(selectedAgent.agent_type) + setSkillTogglingId(null) + } + }, + [ + loadSkills, + selectedAgent, + skillTogglingId, + skillsT, + workspacePathForRequest, + ] + ) + const handleCreateDraft = useCallback(() => { if (!selectedAgent) return setIsDrafting(true) @@ -922,51 +961,95 @@ export function SkillsSettings() { filteredSkills.map((skill) => { const isActive = skill.id === selectedSkillId const deleting = skillDeletingId === skill.id + const availabilityHint = skill.read_only + ? skillsT("availability.readOnly") + : !skill.can_toggle + ? skillsT("availability.cannotIsolate") + : skill.enabled + ? skillsT("availability.enabled") + : skillsT("availability.disabled") return ( - +
{ + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + }} + > + { + handleToggleSkill(skill).catch((err) => { + console.error( + "[SkillsSettings] toggle skill failed:", + err + ) + }) + }} + disabled={ + !skill.can_toggle || + Boolean(skillTogglingId) + } + aria-label={skillsT( + "availability.toggleAria", + { + skill: skill.name, + agent: selectedAgent?.name ?? "", + } + )} + title={availabilityHint} + />
- +
({ + acpListAgentSkills: vi.fn(), +})) + +vi.mock("@/lib/api", () => api) + +import { invalidateAgentSkillsCache, useAgentSkills } from "./use-agent-skills" + +function skill(id: string, enabled: boolean): AgentSkillItem { + return { + id, + name: id, + scope: "global", + layout: "skill_directory", + path: `/home/test/.codex/skills/${id}/SKILL.md`, + description: null, + read_only: false, + enabled, + can_toggle: true, + } +} + +beforeEach(() => { + vi.clearAllMocks() + invalidateAgentSkillsCache() +}) + +describe("useAgentSkills", () => { + it("excludes disabled skills from autocomplete results and its cache", async () => { + api.acpListAgentSkills.mockResolvedValue({ + supported: true, + message: null, + locations: [], + skills: [skill("enabled", true), skill("disabled", false)], + }) + + const first = renderHook(() => useAgentSkills("codex", null)) + + await waitFor(() => + expect(first.result.current.map((item) => item.id)).toEqual(["enabled"]) + ) + first.unmount() + + const cached = renderHook(() => useAgentSkills("codex", null)) + expect(cached.result.current.map((item) => item.id)).toEqual(["enabled"]) + expect(api.acpListAgentSkills).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/use-agent-skills.ts b/src/hooks/use-agent-skills.ts index c254459786..a7ce86802f 100644 --- a/src/hooks/use-agent-skills.ts +++ b/src/hooks/use-agent-skills.ts @@ -26,7 +26,9 @@ function fetchSkills( if (!promise) { promise = acpListAgentSkills({ agentType, workspacePath }) .then((result) => { - const skills = result.supported ? result.skills : EMPTY + const skills = result.supported + ? result.skills.filter((skill) => skill.enabled !== false) + : EMPTY cache.set(key, skills) inflight.delete(key) return skills From a214d0fbad195a594e8ef40fc0b0e5d9b5218da4 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:31:28 +0800 Subject: [PATCH 11/23] fix(skills): guard availability refresh races --- .../settings/skills-settings.test.tsx | 156 +++++++++++++++++- src/components/settings/skills-settings.tsx | 28 +++- src/hooks/use-agent-skills.test.tsx | 32 +++- src/hooks/use-agent-skills.ts | 8 +- 4 files changed, 210 insertions(+), 14 deletions(-) diff --git a/src/components/settings/skills-settings.test.tsx b/src/components/settings/skills-settings.test.tsx index dc3764cd6e..9e4b22113e 100644 --- a/src/components/settings/skills-settings.test.tsx +++ b/src/components/settings/skills-settings.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { NextIntlClientProvider } from "next-intl" import { beforeEach, describe, expect, it, vi } from "vitest" @@ -56,6 +57,22 @@ const agent = { sort_order: 0, } as AcpAgentInfo +const claudeAgent = { + agent_type: "claude_code", + name: "Claude Code", + sort_order: 1, +} as AcpAgentInfo + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + function skill(overrides: Partial = {}): AgentSkillItem { return { id: "demo", @@ -77,8 +94,11 @@ function listResult(item: AgentSkillItem): AgentSkillsListResult { message: null, locations: [ { - scope: "global", - path: "/home/test/.codex/skills", + scope: item.scope, + path: + item.scope === "project" + ? "/work/project/.codex/skills" + : "/home/test/.codex/skills", exists: true, }, ], @@ -176,26 +196,152 @@ describe("SkillsSettings availability", () => { }) it("reloads authoritative state and reports a localized error after failure", async () => { + const reload = deferred() api.acpSetAgentSkillEnabled.mockRejectedValue( new Error("permission denied") ) api.acpListAgentSkills .mockResolvedValueOnce(listResult(skill())) .mockResolvedValueOnce(listResult(skill())) - .mockResolvedValueOnce(listResult(skill())) + .mockReturnValueOnce(reload.promise) renderSettings() const availability = await screen.findByRole("switch", { name: "Toggle Demo Skill for Codex", }) + availability.focus() fireEvent.click(availability) await waitFor(() => expect(api.acpListAgentSkills).toHaveBeenCalledTimes(3)) - expect(availability).toBeChecked() + const switchDuringReload = screen.queryByRole("switch", { + name: "Toggle Demo Skill for Codex", + }) + const focusStayedOnSwitch = document.activeElement === switchDuringReload + + await act(async () => { + reload.resolve(listResult(skill({ enabled: false }))) + await reload.promise + }) + + const authoritativeSwitch = await screen.findByRole("switch", { + name: "Toggle Demo Skill for Codex", + }) + await waitFor(() => expect(authoritativeSwitch).not.toBeChecked()) + expect(switchDuringReload).not.toBeNull() + expect(focusStayedOnSwitch).toBe(true) expect(toast.error).toHaveBeenCalledWith( "Failed to update skill availability", { description: "permission denied" } ) }) + + it("ignores an old agent reload after the selected target changes", async () => { + const staleCodexReload = deferred() + let codexLoads = 0 + const codexSkill = skill({ name: "Codex Skill" }) + const claudeSkill = skill({ + id: "claude-demo", + name: "Claude Skill", + path: "/home/test/.claude/skills/claude-demo/SKILL.md", + }) + + api.acpListAgents.mockResolvedValue([agent, claudeAgent]) + api.acpListAgentSkills.mockImplementation( + (params: { agentType: string; workspacePath?: string | null }) => { + if (!("workspacePath" in params)) { + return Promise.resolve( + listResult(params.agentType === "codex" ? codexSkill : claudeSkill) + ) + } + if (params.agentType === "codex") { + codexLoads += 1 + return codexLoads === 1 + ? Promise.resolve(listResult(codexSkill)) + : staleCodexReload.promise + } + return Promise.resolve(listResult(claudeSkill)) + } + ) + + renderSettings() + + fireEvent.click( + await screen.findByRole("switch", { + name: "Toggle Codex Skill for Codex", + }) + ) + await waitFor(() => expect(codexLoads).toBe(2)) + + const user = userEvent.setup() + await user.click(screen.getByRole("combobox")) + await user.click(await screen.findByRole("option", { name: "Claude Code" })) + await screen.findByRole("switch", { + name: "Toggle Claude Skill for Claude Code", + }) + + await act(async () => { + staleCodexReload.resolve( + listResult(skill({ name: "Stale Codex Skill", enabled: false })) + ) + await staleCodexReload.promise + }) + + expect( + screen.getByRole("switch", { + name: "Toggle Claude Skill for Claude Code", + }) + ).toBeChecked() + expect(screen.queryByText("Stale Codex Skill")).not.toBeInTheDocument() + }) + + it("sends the selected project workspace when toggling a folder skill", async () => { + const projectSkill = skill({ + id: "project-demo", + name: "Project Skill", + scope: "project", + path: "/work/project/.codex/skills/project-demo/SKILL.md", + }) + api.loadFolderHistory.mockResolvedValue([ + { + id: 1, + name: "Project", + path: "/work/project", + last_opened_at: "2026-09-07T00:00:00Z", + }, + ]) + api.acpListAgentSkills.mockImplementation( + (params: { workspacePath?: string | null }) => + Promise.resolve( + params.workspacePath === "/work/project" + ? listResult(projectSkill) + : listResult(skill()) + ) + ) + + renderSettings() + + const user = userEvent.setup() + await user.click(await screen.findByRole("button", { name: "Folder" })) + await waitFor(() => expect(api.loadFolderHistory).toHaveBeenCalledTimes(1)) + await user.click(screen.getAllByRole("combobox")[1]) + await user.click( + await screen.findByRole("option", { name: /Project.*\/work\/project/ }) + ) + fireEvent.click( + await screen.findByRole("switch", { + name: "Toggle Project Skill for Codex", + }) + ) + + await waitFor(() => + expect(api.acpSetAgentSkillEnabled).toHaveBeenCalledWith({ + agentType: "codex", + scope: "project", + skillId: "project-demo", + workspacePath: "/work/project", + enabled: false, + }) + ) + }) }) diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index a89aab912e..a5a7b93029 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -168,6 +168,7 @@ export function SkillsSettings() { const t = useTranslations("SkillsSettings") const skillsT = t as unknown as SkillsTranslator const panelContainerRef = useRef(null) + const skillsLoadGenerationRef = useRef(0) const [panelContainerWidth, setPanelContainerWidth] = useState(0) const [agents, setAgents] = useState([]) const [loadingAgents, setLoadingAgents] = useState(true) @@ -200,6 +201,9 @@ export function SkillsSettings() { skillsScope === "folder" ? selectedFolderPath : null const backendScope: AgentSkillScope = skillsScope === "folder" ? "project" : "global" + const skillsTargetKey = `${selectedAgentType ?? ""}|${backendScope}|${workspacePathForRequest ?? ""}` + const currentSkillsTargetKeyRef = useRef(skillsTargetKey) + currentSkillsTargetKeyRef.current = skillsTargetKey const [skillDraftId, setSkillDraftId] = useState("") const [skillDraftContent, setSkillDraftContent] = useState("") @@ -332,10 +336,19 @@ export function SkillsSettings() { const loadSkills = useCallback( async (agentType: AgentType) => { + const requestTargetKey = `${agentType}|${backendScope}|${workspacePathForRequest ?? ""}` + if (currentSkillsTargetKeyRef.current !== requestTargetKey) return null + + const generation = ++skillsLoadGenerationRef.current + const isCurrentRequest = () => + currentSkillsTargetKeyRef.current === requestTargetKey && + skillsLoadGenerationRef.current === generation + // Folder scope but no folder chosen → skip the fetch; UI prompts the // user to pick one. We still clear previous results so list doesn't // show stale items from another folder. if (skillsScope === "folder" && !workspacePathForRequest) { + setSkillsLoading(false) setSkillsError(null) setSkillsSupported(true) setSkillLocation(null) @@ -351,6 +364,8 @@ export function SkillsSettings() { agentType, workspacePath: workspacePathForRequest, }) + if (!isCurrentRequest()) return result + setSkillsSupported(result.supported) setSkillLocation( result.locations.find( @@ -362,6 +377,8 @@ export function SkillsSettings() { ) return result } catch (err) { + if (!isCurrentRequest()) return null + const message = toErrorMessage(err) setSkillsError(message) setSkillsSupported(true) @@ -369,7 +386,7 @@ export function SkillsSettings() { setSkillItems([]) return null } finally { - setSkillsLoading(false) + if (isCurrentRequest()) setSkillsLoading(false) } }, [backendScope, skillsScope, workspacePathForRequest] @@ -710,6 +727,10 @@ export function SkillsSettings() { // template here anymore — the right panel shows a placeholder until the // user picks a skill from the list or clicks "New Skill". setSelectedSkillId(null) + setSkillsError(null) + setSkillsSupported(true) + setSkillLocation(null) + setSkillItems([]) setSkillDraftId("") setSkillDraftContent("") setIsContentEditing(false) @@ -927,7 +948,7 @@ export function SkillsSettings() {
- {skillsLoading && ( + {skillsLoading && skillItems.length === 0 && (
{t("loadingSkills")} @@ -956,8 +977,7 @@ export function SkillsSettings() {
)} - {!skillsLoading && - skillsSupported && + {skillsSupported && filteredSkills.map((skill) => { const isActive = skill.id === selectedSkillId const deleting = skillDeletingId === skill.id diff --git a/src/hooks/use-agent-skills.test.tsx b/src/hooks/use-agent-skills.test.tsx index 8d4f5b7642..e7cb9c5366 100644 --- a/src/hooks/use-agent-skills.test.tsx +++ b/src/hooks/use-agent-skills.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from "@testing-library/react" +import { act, renderHook, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import type { AgentSkillItem } from "@/lib/types" @@ -50,4 +50,34 @@ describe("useAgentSkills", () => { expect(cached.result.current.map((item) => item.id)).toEqual(["enabled"]) expect(api.acpListAgentSkills).toHaveBeenCalledTimes(1) }) + + it("replaces a warm cached result after a focus refresh", async () => { + api.acpListAgentSkills + .mockResolvedValueOnce({ + supported: true, + message: null, + locations: [], + skills: [skill("demo", true)], + }) + .mockResolvedValueOnce({ + supported: true, + message: null, + locations: [], + skills: [skill("demo", false)], + }) + + const prime = renderHook(() => useAgentSkills("codex", null)) + await waitFor(() => expect(prime.result.current).toHaveLength(1)) + prime.unmount() + + const warm = renderHook(() => useAgentSkills("codex", null)) + expect(warm.result.current).toHaveLength(1) + + act(() => { + window.dispatchEvent(new Event("focus")) + }) + + await waitFor(() => expect(warm.result.current).toEqual([])) + expect(api.acpListAgentSkills).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/hooks/use-agent-skills.ts b/src/hooks/use-agent-skills.ts index a7ce86802f..fd81a499a1 100644 --- a/src/hooks/use-agent-skills.ts +++ b/src/hooks/use-agent-skills.ts @@ -52,10 +52,10 @@ export function useAgentSkills( () => (agentType ? makeKey(agentType, normalizedPath) : null), [agentType, normalizedPath] ) - const cached = useMemo( - () => (cacheKey ? (cache.get(cacheKey) ?? null) : null), - [cacheKey] - ) + // Read the mutable cache on every render. A focus refresh updates the Map + // before setFetched triggers a render, so a mount-time snapshot cannot mask + // the authoritative replacement. + const cached = cacheKey ? (cache.get(cacheKey) ?? null) : null // Track which (agentType, workspacePath) the fetched result belongs to so // stale data from a previous key is never returned after a switch. const [fetched, setFetched] = useState<{ From 761b8d27587fb6d1c4918ecf1870145da65a2284 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:47:13 +0800 Subject: [PATCH 12/23] fix(skills): expose shared isolation capability in listings --- src-tauri/src/commands/acp.rs | 343 +++++++++++++++++++++++++++------- 1 file changed, 271 insertions(+), 72 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 163d886445..324cb59ad3 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8983,7 +8983,7 @@ fn skill_root_writable(root: &Path) -> bool { } } -fn unique_skill_root(peer: &SkillPeer, peers: &[SkillPeer]) -> Result { +fn unique_skill_root(peer: &SkillPeer, peers: &[SkillPeer]) -> Result, AcpError> { for root in &peer.roots { let resolved = resolved_skill_root(root)?; if is_read_only_skill_path(peer.agent, root) @@ -9004,13 +9004,116 @@ fn unique_skill_root(peer: &SkillPeer, peers: &[SkillPeer]) -> Result( + selected: &SkillPeer, + peers: &'a [SkillPeer], + root: &Path, + layout: AgentSkillLayout, +) -> Result, AcpError> { + let resolved_root = resolved_skill_root(root)?; + let mut affected = Vec::new(); + for peer in peers { + let mut shares = false; + for scan in &peer.roots { + let resolved_scan = resolved_skill_root(scan)?; + if skill_roots_overlap(&resolved_root, &resolved_scan) { + if resolved_root != resolved_scan { + return Err(AcpError::protocol( + "shared skill root has overlapping scan roots that cannot be isolated", + )); + } + shares = true; + } + } + if shares + && (peer.agent != selected.agent || peer.scope != selected.scope) + && !(layout == AgentSkillLayout::MarkdownFile + && peer.kind == SkillStorageKind::SkillDirectoryOnly) + { + affected.push(peer); + } + } + Ok(affected) +} + +fn shared_skill_restore_links( + affected: &[&SkillPeer], + id: &str, + canonical: &Path, +) -> Result, AcpError> { + let mut links = Vec::new(); + for peer in affected { + reject_multiple_active_skills(&peer.roots, peer.kind, id)?; + let active = locate_existing_skill_across_dirs(&peer.roots, peer.kind, id, peer.scope) + .filter(|item| item.enabled) + .ok_or_else(|| { + AcpError::protocol("shared skill restore would reenable a disabled peer") + })?; + let path = PathBuf::from(active.path); + if !skill_link_targets(&path, canonical) { + return Err(AcpError::protocol( + "shared skill restore would conflict with an independent peer skill", + )); + } + if !path.parent().is_some_and(skill_root_writable) { + return Err(AcpError::protocol( + "shared skill restore link root is not writable", + )); + } + links.push(path); + } + Ok(links) +} + +fn listed_skill_can_toggle( + selected: &SkillPeer, + peers: &[SkillPeer], + skill: &AgentSkillItem, + workspace_path: Option<&str>, +) -> Result { + let parent = Path::new(&skill.path).parent(); + let root = selected + .roots + .iter() + .find(|root| { + if skill.enabled { + parent == Some(root.as_path()) + } else { + parent == Some(disabled_skill_root(root).as_path()) + } + }) + .ok_or_else(|| AcpError::protocol("listed skill has no owning native root"))?; + if !skill_root_is_shared_with_peers(selected.agent, selected.scope, root, peers)? { + return Ok(true); + } + preflight_shared_skill_owner(root, peers)?; + preflight_disabled_skill_root(root, workspace_path)?; + let affected = shared_skill_affected_peers(selected, peers, root, skill.layout)?; + if skill.enabled { + if !skill_root_writable(root) || !skill_root_writable(&disabled_skill_root(root)) { + return Ok(false); + } + for peer in affected { + if unique_skill_root(peer, peers)?.is_none() { + return Ok(false); + } + } + return Ok(true); + } + if unique_skill_root(selected, peers)?.is_some() { + return Ok(true); + } + if !skill_root_writable(root) || !skill_root_writable(&disabled_skill_root(root)) { + return Ok(false); + } + shared_skill_restore_links(&affected, &skill.id, Path::new(&skill.path))?; + Ok(true) } fn preflight_shared_skill_owner(root: &Path, peers: &[SkillPeer]) -> Result<(), AcpError> { @@ -9219,83 +9322,35 @@ fn set_shared_skill_enabled( preflight_skill_destination(&disabled_skill_root(scan), &id)?; } preflight_skill_symlink_move(Path::new(&original.path), &vault)?; - for peer in peers { - let mut shares = false; - for scan in &peer.roots { - let resolved_scan = resolved_skill_root(scan)?; - if skill_roots_overlap(&resolved_root, &resolved_scan) { - if resolved_root != resolved_scan { - return Err(AcpError::protocol( - "shared skill root has overlapping scan roots that cannot be isolated", - )); - } - shares = true; - } - } - if !shares || (peer.agent == selected.agent && peer.scope == selected.scope) { - continue; - } - // A flat markdown file is invisible to directory-only consumers. - if canonical_item.layout == AgentSkillLayout::MarkdownFile - && peer.kind == SkillStorageKind::SkillDirectoryOnly - { - continue; - } + for peer in shared_skill_affected_peers(selected, peers, root, canonical_item.layout)? { reject_multiple_active_skills(&peer.roots, peer.kind, &id)?; for scan in &peer.roots { preflight_skill_destination(&disabled_skill_root(scan), &id)?; } - let destination_root = unique_skill_root(peer, peers)?; + let destination_root = unique_skill_root(peer, peers)?.ok_or_else(|| { + AcpError::protocol(format!( + "shared skill root: {} has no unique writable root", + peer.agent + )) + })?; preflight_skill_destination(&destination_root, &id)?; destinations.push(destination_root.join(file_name)); affected.push(peer); } } else if enabled { - match unique_skill_root(selected, peers) { - Ok(destination_root) => { + match unique_skill_root(selected, peers)? { + Some(destination_root) => { preflight_skill_destination(&destination_root, &id)?; destinations.push(destination_root.join(file_name)); } - Err(error) if error.to_string().contains("no unique writable root") => { + None => { preflight_skill_destination(root, &id)?; preflight_skill_symlink_move(&canonical, root)?; - for peer in peers { - if peer.agent == selected.agent && peer.scope == selected.scope { - continue; - } - let shares = peer - .roots - .iter() - .map(|scan| resolved_skill_root(scan)) - .collect::, _>>()? - .iter() - .any(|scan| skill_roots_overlap(scan, &resolved_root)); - if !shares - || (canonical_item.layout == AgentSkillLayout::MarkdownFile - && peer.kind == SkillStorageKind::SkillDirectoryOnly) - { - continue; - } - reject_multiple_active_skills(&peer.roots, peer.kind, &id)?; - let active = - locate_existing_skill_across_dirs(&peer.roots, peer.kind, &id, peer.scope) - .filter(|item| item.enabled) - .ok_or_else(|| { - AcpError::protocol( - "shared skill restore would reenable a disabled peer", - ) - })?; - if !skill_link_targets(Path::new(&active.path), &canonical) { - return Err(AcpError::protocol( - "shared skill restore would conflict with an independent peer skill", - )); - } - redundant_links.push(PathBuf::from(active.path)); - affected.push(peer); - } + affected = + shared_skill_affected_peers(selected, peers, root, canonical_item.layout)?; + redundant_links = shared_skill_restore_links(&affected, &id, &canonical)?; restore_shared = true; } - Err(error) => return Err(error), } } else if !skill_link_targets(Path::new(&original.path), &canonical) { return Err(AcpError::protocol( @@ -9406,13 +9461,24 @@ fn skill_root_is_shared( scope: AgentSkillScope, workspace_path: Option<&str>, root: &Path, +) -> Result { + skill_root_is_shared_with_peers(agent_type, scope, root, &skill_peers(workspace_path)) +} + +fn skill_root_is_shared_with_peers( + agent_type: AgentType, + scope: AgentSkillScope, + root: &Path, + peers: &[SkillPeer], ) -> Result { let resolved_root = resolved_skill_root(root)?; - for (peer, peer_scope, peer_root) in native_skill_roots(workspace_path) { - if (peer != agent_type || peer_scope != scope) - && skill_roots_overlap(&resolved_root, &resolved_skill_root(&peer_root)?) - { - return Ok(true); + for peer in peers { + if peer.agent != agent_type || peer.scope != scope { + for peer_root in &peer.roots { + if skill_roots_overlap(&resolved_root, &resolved_skill_root(peer_root)?) { + return Ok(true); + } + } } } Ok(false) @@ -13556,8 +13622,18 @@ pub async fn acp_list_agent_skills( } let mut skills = skills_by_key.into_values().collect::>(); + let peers = skill_peers(workspace_path.as_deref()); for skill in &mut skills { apply_skill_capabilities(agent_type, skill); + if skill.can_toggle { + skill.can_toggle = peers + .iter() + .find(|peer| peer.agent == agent_type && peer.scope == skill.scope) + .is_some_and(|selected| { + listed_skill_can_toggle(selected, &peers, skill, workspace_path.as_deref()) + .unwrap_or(false) + }); + } } skills.sort_by(|a, b| { scope_rank(a.scope) @@ -16938,6 +17014,129 @@ wire_api = "chat" (shared, peers) } + fn skill_capability_project_fixture(base: &Path) -> PathBuf { + let shared = base.join(".claude/skills"); + fs::create_dir_all(shared.join("capability-demo")).unwrap(); + fs::write(shared.join("capability-demo/SKILL.md"), "body").unwrap(); + shared + } + + fn skill_capability_list_project( + runtime: &tokio::runtime::Runtime, + agent: AgentType, + base: &Path, + ) -> AgentSkillItem { + runtime + .block_on(acp_list_agent_skills( + agent, + Some(base.to_string_lossy().into_owned()), + )) + .unwrap() + .skills + .into_iter() + .find(|item| item.scope == AgentSkillScope::Project && item.id == "capability-demo") + .unwrap() + } + + #[test] + fn skill_capability_enabled_shared_without_unique_peer_root_is_false() { + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let item = skill_capability_list_project(&runtime, AgentType::Cline, tmp.path()); + assert!(item.enabled); + assert!( + !item.can_toggle, + "Claude has no unique root to preserve its enabled state" + ); + assert!(shared.join("capability-demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).exists()); + assert!(!tmp.path().join(".cline").exists()); + } + + #[test] + fn skill_capability_feasible_shared_and_private_entries_are_true() { + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + let private = tmp.path().join(".codex/skills/capability-demo"); + fs::create_dir_all(&private).unwrap(); + fs::write(private.join("SKILL.md"), "private").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + for agent in [AgentType::ClaudeCode, AgentType::Codex] { + assert!(skill_capability_list_project(&runtime, agent, tmp.path()).can_toggle); + } + assert!(!disabled_skill_root(&shared).exists()); + assert!(!tmp.path().join(".cline").exists()); + } + + #[test] + fn skill_capability_disabled_shared_restore_is_true_when_peers_enabled() { + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::ClaudeCode, + AgentSkillScope::Project, + "capability-demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )) + .unwrap(); + let item = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); + assert!(!item.enabled); + assert!(item.can_toggle); + assert!(!shared.join("capability-demo").exists()); + assert!(disabled_skill_root(&shared) + .join("capability-demo/SKILL.md") + .is_file()); + assert!(tmp + .path() + .join(".cline/skills/capability-demo/SKILL.md") + .is_file()); + } + + #[test] + fn skill_capability_disabled_shared_restore_is_false_when_peer_disabled() { + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + for agent in [AgentType::ClaudeCode, AgentType::Cline] { + runtime + .block_on(acp_set_agent_skill_enabled( + agent, + AgentSkillScope::Project, + "capability-demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )) + .unwrap(); + } + let item = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); + assert!(!item.enabled); + assert!(!item.can_toggle, "restoring shared root would enable Cline"); + assert!(!shared.join("capability-demo").exists()); + assert!(disabled_skill_root(&shared) + .join("capability-demo/SKILL.md") + .is_file()); + assert!(fs::symlink_metadata(tmp.path().join(".cline/skills/capability-demo")).is_err()); + } + + #[cfg(unix)] + #[test] + fn skill_capability_planning_error_keeps_item_but_disables_toggle() { + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + fs::create_dir_all(tmp.path().join(".clinerules")).unwrap(); + std::os::unix::fs::symlink("missing", tmp.path().join(".clinerules/skills")).unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let item = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); + assert!(!item.can_toggle); + assert!(item.enabled); + assert!(shared.join("capability-demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).exists()); + } + fn shared_skill_custom_def( id: &str, root: &Path, From 473bb36722e5a222781c548878e463c804d992b1 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:00:48 +0800 Subject: [PATCH 13/23] fix(skills): align capability planning with toggle preflight --- src-tauri/src/commands/acp.rs | 195 +++++++++++++++++++++++++--------- 1 file changed, 146 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 324cb59ad3..28b1d06141 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -9071,12 +9071,46 @@ fn shared_skill_restore_links( Ok(links) } +fn plan_shared_skill_fanout<'a>( + selected: &SkillPeer, + peers: &'a [SkillPeer], + root: &Path, + skill: &AgentSkillItem, +) -> Result, AcpError> { + let vault = disabled_skill_root(root); + preflight_skill_destination(&vault, &skill.id)?; + for scan in &selected.roots { + preflight_skill_destination(&disabled_skill_root(scan), &skill.id)?; + } + let source = Path::new(&skill.path); + preflight_skill_symlink_move(source, &vault)?; + let filename = source + .file_name() + .ok_or_else(|| AcpError::protocol("skill has no filename"))?; + let mut destinations = Vec::new(); + for peer in shared_skill_affected_peers(selected, peers, root, skill.layout)? { + reject_multiple_active_skills(&peer.roots, peer.kind, &skill.id)?; + for scan in &peer.roots { + preflight_skill_destination(&disabled_skill_root(scan), &skill.id)?; + } + let destination_root = unique_skill_root(peer, peers)?.ok_or_else(|| { + AcpError::protocol(format!( + "shared skill root: {} has no unique writable root", + peer.agent + )) + })?; + preflight_skill_destination(&destination_root, &skill.id)?; + destinations.push((peer, destination_root.join(filename))); + } + Ok(destinations) +} + fn listed_skill_can_toggle( selected: &SkillPeer, peers: &[SkillPeer], skill: &AgentSkillItem, - workspace_path: Option<&str>, ) -> Result { + reject_multiple_active_skills(&selected.roots, selected.kind, &skill.id)?; let parent = Path::new(&skill.path).parent(); let root = selected .roots @@ -9093,25 +9127,24 @@ fn listed_skill_can_toggle( return Ok(true); } preflight_shared_skill_owner(root, peers)?; - preflight_disabled_skill_root(root, workspace_path)?; - let affected = shared_skill_affected_peers(selected, peers, root, skill.layout)?; + preflight_disabled_skill_root_with_peers(root, peers)?; if skill.enabled { if !skill_root_writable(root) || !skill_root_writable(&disabled_skill_root(root)) { return Ok(false); } - for peer in affected { - if unique_skill_root(peer, peers)?.is_none() { - return Ok(false); - } - } + plan_shared_skill_fanout(selected, peers, root, skill)?; return Ok(true); } - if unique_skill_root(selected, peers)?.is_some() { + if let Some(destination_root) = unique_skill_root(selected, peers)? { + preflight_skill_destination(&destination_root, &skill.id)?; return Ok(true); } if !skill_root_writable(root) || !skill_root_writable(&disabled_skill_root(root)) { return Ok(false); } + preflight_skill_destination(root, &skill.id)?; + preflight_skill_symlink_move(Path::new(&skill.path), root)?; + let affected = shared_skill_affected_peers(selected, peers, root, skill.layout)?; shared_skill_restore_links(&affected, &skill.id, Path::new(&skill.path))?; Ok(true) } @@ -9282,23 +9315,7 @@ fn set_shared_skill_enabled( let resolved_root = resolved_skill_root(root)?; let vault = disabled_skill_root(root); let resolved_vault = resolved_skill_root(&vault)?; - for peer in peers { - for native in &peer.roots { - let resolved_native = resolved_skill_root(native)?; - if skill_roots_overlap(&resolved_vault, &resolved_native) { - return Err(AcpError::protocol( - "disabled skill vault overlaps native scan root", - )); - } - if resolved_native != resolved_root - && resolved_skill_root(&disabled_skill_root(native))? == resolved_vault - { - return Err(AcpError::protocol( - "shared skill storage: disabled vault has multiple native owners", - )); - } - } - } + preflight_disabled_skill_root_with_peers(root, peers)?; let first_disable = original.enabled && resolved_skill_root(Path::new(&original.path).parent().expect("skill parent"))? == resolved_root; @@ -9317,24 +9334,8 @@ fn set_shared_skill_enabled( let mut restore_shared = false; let mut redundant_links = Vec::new(); if first_disable { - preflight_skill_destination(&vault, &id)?; - for scan in &selected.roots { - preflight_skill_destination(&disabled_skill_root(scan), &id)?; - } - preflight_skill_symlink_move(Path::new(&original.path), &vault)?; - for peer in shared_skill_affected_peers(selected, peers, root, canonical_item.layout)? { - reject_multiple_active_skills(&peer.roots, peer.kind, &id)?; - for scan in &peer.roots { - preflight_skill_destination(&disabled_skill_root(scan), &id)?; - } - let destination_root = unique_skill_root(peer, peers)?.ok_or_else(|| { - AcpError::protocol(format!( - "shared skill root: {} has no unique writable root", - peer.agent - )) - })?; - preflight_skill_destination(&destination_root, &id)?; - destinations.push(destination_root.join(file_name)); + for (peer, destination) in plan_shared_skill_fanout(selected, peers, root, &original)? { + destinations.push(destination); affected.push(peer); } } else if enabled { @@ -9487,12 +9488,19 @@ fn skill_root_is_shared_with_peers( fn preflight_disabled_skill_root( root: &Path, workspace_path: Option<&str>, +) -> Result<(), AcpError> { + preflight_disabled_skill_root_with_peers(root, &skill_peers(workspace_path)) +} + +fn preflight_disabled_skill_root_with_peers( + root: &Path, + peers: &[SkillPeer], ) -> Result<(), AcpError> { let vault = disabled_skill_root(root); let resolved_vault = resolved_skill_root(&vault)?; let resolved_root = resolved_skill_root(root)?; - for (_, _, native_root) in native_skill_roots(workspace_path) { - let resolved_native = resolved_skill_root(&native_root)?; + for native_root in peers.iter().flat_map(|peer| &peer.roots) { + let resolved_native = resolved_skill_root(native_root)?; if skill_roots_overlap(&resolved_vault, &resolved_native) { return Err(AcpError::protocol(format!( "disabled skill vault '{}' overlaps native scan root '{}'", @@ -9501,7 +9509,7 @@ fn preflight_disabled_skill_root( ))); } if resolved_native != resolved_root - && resolved_vault == resolved_skill_root(&disabled_skill_root(&native_root))? + && resolved_vault == resolved_skill_root(&disabled_skill_root(native_root))? { return Err(AcpError::protocol(format!( "shared skill storage: disabled vault '{}' is shared by native roots '{}' and '{}'; toggling is unsupported", @@ -13630,8 +13638,7 @@ pub async fn acp_list_agent_skills( .iter() .find(|peer| peer.agent == agent_type && peer.scope == skill.scope) .is_some_and(|selected| { - listed_skill_can_toggle(selected, &peers, skill, workspace_path.as_deref()) - .unwrap_or(false) + listed_skill_can_toggle(selected, &peers, skill).unwrap_or(false) }); } } @@ -17137,6 +17144,96 @@ wire_api = "chat" assert!(!disabled_skill_root(&shared).exists()); } + #[test] + fn skill_capability_unique_enable_ignores_unneeded_shared_restore_peers() { + use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; + let _registry_guard = hydrate_test_guard(); + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let workspace = Some(tmp.path().to_string_lossy().into_owned()); + for agent in [AgentType::ClaudeCode, AgentType::Cline] { + runtime + .block_on(acp_set_agent_skill_enabled( + agent, + AgentSkillScope::Project, + "capability-demo".into(), + workspace.clone(), + false, + )) + .unwrap(); + } + let nested = shared.join("nested/skills"); + fs::create_dir_all(&nested).unwrap(); + assert!(hydrate(&[shared_skill_custom_def("capability-nested-peer", &nested)]).is_empty()); + let listed = skill_capability_list_project(&runtime, AgentType::Cline, tmp.path()); + assert!(!tmp.path().join(".cline/skills/capability-demo").exists()); + let enabled = runtime.block_on(acp_set_agent_skill_enabled( + AgentType::Cline, + AgentSkillScope::Project, + "capability-demo".into(), + workspace, + true, + )); + hydrate(&[]); + assert!( + enabled.unwrap().enabled, + "execution can enable via Cline's unique root" + ); + assert!( + listed.can_toggle, + "unique enable does not restore the shared root" + ); + } + + #[test] + fn skill_capability_shared_peer_duplicate_is_false_before_fanout() { + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + let independent = tmp.path().join(".cline/skills/capability-demo"); + fs::create_dir_all(&independent).unwrap(); + fs::write(independent.join("SKILL.md"), "independent").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let listed = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); + let error = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::ClaudeCode, + AgentSkillScope::Project, + "capability-demo".into(), + Some(tmp.path().to_string_lossy().into_owned()), + false, + )) + .unwrap_err(); + assert!(error.to_string().contains("multiple active skills")); + assert!( + !listed.can_toggle, + "existing peer duplicate blocks the same fanout execution" + ); + assert!(shared.join("capability-demo/SKILL.md").is_file()); + assert_eq!( + fs::read_to_string(independent.join("SKILL.md")).unwrap(), + "independent" + ); + assert!(!disabled_skill_root(&shared).exists()); + } + + #[test] + fn skill_capability_existing_fanout_destination_conflict_is_false() { + let tmp = tempfile::tempdir().unwrap(); + let shared = skill_capability_project_fixture(tmp.path()); + let peer_root = tmp.path().join(".cline/skills"); + fs::create_dir_all(&peer_root).unwrap(); + fs::write(peer_root.join("capability-demo"), "occupied").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let listed = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); + assert!( + !listed.can_toggle, + "existing destination conflicts are deterministic blockers" + ); + assert!(shared.join("capability-demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&shared).exists()); + } + fn shared_skill_custom_def( id: &str, root: &Path, From 4bdaa3c9eccc4ccc9c518123eb9ca9806f9978ab Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:48:10 +0800 Subject: [PATCH 14/23] feat(i18n): localize skill availability controls --- src/i18n/messages.test.ts | 38 ++++++++++++++++++++++++++++++++++++ src/i18n/messages/ar.json | 12 +++++++++++- src/i18n/messages/de.json | 12 +++++++++++- src/i18n/messages/en.json | 12 +++++++++++- src/i18n/messages/es.json | 12 +++++++++++- src/i18n/messages/fr.json | 12 +++++++++++- src/i18n/messages/ja.json | 12 +++++++++++- src/i18n/messages/ko.json | 12 +++++++++++- src/i18n/messages/pt.json | 12 +++++++++++- src/i18n/messages/zh-CN.json | 12 +++++++++++- src/i18n/messages/zh-TW.json | 12 +++++++++++- 11 files changed, 148 insertions(+), 10 deletions(-) diff --git a/src/i18n/messages.test.ts b/src/i18n/messages.test.ts index 5314eb12c9..d8e165ffda 100644 --- a/src/i18n/messages.test.ts +++ b/src/i18n/messages.test.ts @@ -62,6 +62,44 @@ const ALL_LOCALES = [ ["zh-TW", zhTW], ] as const +const SKILL_AVAILABILITY_MESSAGES = [ + ["SkillsSettings.availability.enabled", []], + ["SkillsSettings.availability.disabled", []], + ["SkillsSettings.availability.toggleAria", ["agent", "skill"]], + ["SkillsSettings.availability.readOnly", []], + ["SkillsSettings.availability.cannotIsolate", []], + ["SkillsSettings.toasts.enabled", []], + ["SkillsSettings.toasts.disabled", []], + ["SkillsSettings.toasts.toggleFailed", []], +] as const + +function getMessage(node: MessageNode, path: string): string | undefined { + let current: MessageNode | undefined = node + for (const segment of path.split(".")) { + if (typeof current === "string") return undefined + current = current?.[segment] + } + return typeof current === "string" ? current : undefined +} + +describe("skill availability message contract", () => { + it.each(ALL_LOCALES)( + "%s includes the required keys and placeholders", + (_locale, messages) => { + for (const [key, expectedPlaceholders] of SKILL_AVAILABILITY_MESSAGES) { + const message = getMessage(messages as MessageNode, key) + expect(message, key).toBeTypeOf("string") + const placeholders = [ + ...(message?.matchAll(/\{([a-zA-Z][\w]*)\}/g) ?? []), + ] + .map((match) => match[1]) + .sort() + expect(placeholders, key).toEqual(expectedPlaceholders) + } + } + ) +}) + // Every message goes through ICU MessageFormat, which reserves ``, `{`, // `}` and `#`. A string like `/settings.json` parses as an // unclosed tag and falls back to rendering the KEY — visible in the UI as diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 437945dcb1..402ca1bf40 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -544,6 +544,13 @@ "noSelectionHint": "اختر Skill من اليسار، أو انقر على \"Skill جديد\" لإنشاء واحد.", "systemBadge": "نظام", "systemHint": "Skill مدمج في CLI · للقراءة فقط", + "availability": { + "enabled": "مفعّلة", + "disabled": "معطّلة", + "toggleAria": "تبديل حالة {skill} للوكيل {agent}", + "readOnly": "المهارات المدمجة متاحة دائمًا.", + "cannotIsolate": "لا يمكن تبديل هذه المهارة المشتركة بشكل مستقل." + }, "scope": { "global": "عام", "folder": "مجلد", @@ -579,7 +586,10 @@ "created": "تم إنشاء Skill", "saveFailed": "فشل حفظ Skill", "deleted": "تم حذف Skill", - "deleteFailed": "فشل حذف Skill" + "deleteFailed": "فشل حذف Skill", + "enabled": "تم تفعيل Skill", + "disabled": "تم تعطيل Skill", + "toggleFailed": "فشل تحديث حالة توفر Skill" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9e80735d19..d35f5ffbbf 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -544,6 +544,13 @@ "noSelectionHint": "Wählen Sie links einen Skill oder klicken Sie auf „Neuer Skill“, um einen zu erstellen.", "systemBadge": "System", "systemHint": "Integrierter CLI-Skill · schreibgeschützt", + "availability": { + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "toggleAria": "{skill} für {agent} umschalten", + "readOnly": "Integrierte Skills sind immer verfügbar.", + "cannotIsolate": "Dieser gemeinsam genutzte Skill kann nicht separat umgeschaltet werden." + }, "scope": { "global": "Global", "folder": "Ordner", @@ -579,7 +586,10 @@ "created": "Skill erstellt", "saveFailed": "Skill konnte nicht gespeichert werden", "deleted": "Skill gelöscht", - "deleteFailed": "Skill konnte nicht gelöscht werden" + "deleteFailed": "Skill konnte nicht gelöscht werden", + "enabled": "Skill aktiviert", + "disabled": "Skill deaktiviert", + "toggleFailed": "Skill-Verfügbarkeit konnte nicht aktualisiert werden" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 90f4db8c68..4e3e6e61f6 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -544,6 +544,13 @@ "noSelectionHint": "Select a skill on the left, or click \"New Skill\" to create one.", "systemBadge": "System", "systemHint": "Built-in CLI skill · read-only", + "availability": { + "enabled": "Enabled", + "disabled": "Disabled", + "toggleAria": "Toggle {skill} for {agent}", + "readOnly": "Built-in skills are always available.", + "cannotIsolate": "This shared skill cannot be toggled independently." + }, "scope": { "global": "Global", "folder": "Folder", @@ -579,7 +586,10 @@ "created": "Skill created", "saveFailed": "Failed to save skill", "deleted": "Skill deleted", - "deleteFailed": "Failed to delete skill" + "deleteFailed": "Failed to delete skill", + "enabled": "Skill enabled", + "disabled": "Skill disabled", + "toggleFailed": "Failed to update skill availability" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2bce52a8bf..c3cfe55693 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -544,6 +544,13 @@ "noSelectionHint": "Selecciona un Skill a la izquierda o haz clic en \"Nuevo Skill\" para crear uno.", "systemBadge": "Sistema", "systemHint": "Skill integrado del CLI · solo lectura", + "availability": { + "enabled": "Activada", + "disabled": "Desactivada", + "toggleAria": "Cambiar el estado de {skill} para {agent}", + "readOnly": "Las Skills integradas siempre están disponibles.", + "cannotIsolate": "Esta Skill compartida no se puede activar o desactivar de forma independiente." + }, "scope": { "global": "Global", "folder": "Carpeta", @@ -579,7 +586,10 @@ "created": "Skill creada", "saveFailed": "No se pudo guardar la Skill", "deleted": "Skill eliminada", - "deleteFailed": "No se pudo eliminar la Skill" + "deleteFailed": "No se pudo eliminar la Skill", + "enabled": "Skill activada", + "disabled": "Skill desactivada", + "toggleFailed": "No se pudo actualizar la disponibilidad de la Skill" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index adcf62f0dc..3878608452 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -544,6 +544,13 @@ "noSelectionHint": "Sélectionnez un Skill à gauche ou cliquez sur « Nouveau Skill » pour en créer un.", "systemBadge": "Système", "systemHint": "Skill intégré du CLI · lecture seule", + "availability": { + "enabled": "Activée", + "disabled": "Désactivée", + "toggleAria": "Activer ou désactiver {skill} pour {agent}", + "readOnly": "Les Skills intégrées sont toujours disponibles.", + "cannotIsolate": "Cette Skill partagée ne peut pas être activée ou désactivée indépendamment." + }, "scope": { "global": "Global", "folder": "Dossier", @@ -579,7 +586,10 @@ "created": "Skill créée", "saveFailed": "Échec de l’enregistrement de la Skill", "deleted": "Skill supprimée", - "deleteFailed": "Échec de la suppression de la Skill" + "deleteFailed": "Échec de la suppression de la Skill", + "enabled": "Skill activée", + "disabled": "Skill désactivée", + "toggleFailed": "Échec de la mise à jour de la disponibilité de la Skill" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c2cf389d89..eaf33524d3 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -544,6 +544,13 @@ "noSelectionHint": "左側から Skill を選択するか、「新規 Skill」をクリックして作成してください。", "systemBadge": "システム", "systemHint": "CLI 組み込み Skill・読み取り専用", + "availability": { + "enabled": "有効", + "disabled": "無効", + "toggleAria": "{agent} で {skill} の有効状態を切り替える", + "readOnly": "組み込みの Skill は常に利用できます。", + "cannotIsolate": "この共有 Skill は個別に切り替えできません。" + }, "scope": { "global": "グローバル", "folder": "フォルダ", @@ -579,7 +586,10 @@ "created": "Skillを作成しました", "saveFailed": "Skillの保存に失敗しました", "deleted": "Skillを削除しました", - "deleteFailed": "Skillの削除に失敗しました" + "deleteFailed": "Skillの削除に失敗しました", + "enabled": "Skillを有効にしました", + "disabled": "Skillを無効にしました", + "toggleFailed": "Skillの利用可否の更新に失敗しました" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 32f288ec3a..ef97d7fe86 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -544,6 +544,13 @@ "noSelectionHint": "왼쪽에서 Skill을 선택하거나 \"새 Skill\"을 클릭하여 만드세요.", "systemBadge": "시스템", "systemHint": "CLI 내장 Skill · 읽기 전용", + "availability": { + "enabled": "사용", + "disabled": "사용 안 함", + "toggleAria": "{agent}에서 {skill} 사용 여부 전환", + "readOnly": "내장 Skill은 항상 사용할 수 있습니다.", + "cannotIsolate": "이 공유 Skill은 개별적으로 전환할 수 없습니다." + }, "scope": { "global": "전역", "folder": "폴더", @@ -579,7 +586,10 @@ "created": "Skill이 생성되었습니다", "saveFailed": "Skill 저장에 실패했습니다", "deleted": "Skill이 삭제되었습니다", - "deleteFailed": "Skill 삭제에 실패했습니다" + "deleteFailed": "Skill 삭제에 실패했습니다", + "enabled": "Skill이 활성화되었습니다", + "disabled": "Skill이 비활성화되었습니다", + "toggleFailed": "Skill 사용 여부를 업데이트하지 못했습니다" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 071e5867b3..6506acdbfe 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -544,6 +544,13 @@ "noSelectionHint": "Selecione um Skill à esquerda ou clique em \"Novo Skill\" para criar um.", "systemBadge": "Sistema", "systemHint": "Skill integrado do CLI · somente leitura", + "availability": { + "enabled": "Ativada", + "disabled": "Desativada", + "toggleAria": "Alternar {skill} para {agent}", + "readOnly": "As Skills integradas estão sempre disponíveis.", + "cannotIsolate": "Esta Skill compartilhada não pode ser alternada de forma independente." + }, "scope": { "global": "Global", "folder": "Pasta", @@ -579,7 +586,10 @@ "created": "Skill criada", "saveFailed": "Falha ao salvar Skill", "deleted": "Skill excluída", - "deleteFailed": "Falha ao excluir Skill" + "deleteFailed": "Falha ao excluir Skill", + "enabled": "Skill ativada", + "disabled": "Skill desativada", + "toggleFailed": "Falha ao atualizar a disponibilidade da Skill" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b2fccaed4c..94a620bd5e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -544,6 +544,13 @@ "noSelectionHint": "从左侧选择一个 Skill,或点击“新建 Skill”创建。", "systemBadge": "系统", "systemHint": "CLI 内置 Skill · 只读", + "availability": { + "enabled": "已启用", + "disabled": "已禁用", + "toggleAria": "为 {agent} 切换 {skill} 的启用状态", + "readOnly": "内置 Skill 始终可用。", + "cannotIsolate": "此共享 Skill 无法单独切换。" + }, "scope": { "global": "全局", "folder": "文件夹", @@ -579,7 +586,10 @@ "created": "Skill 已创建", "saveFailed": "保存 Skill 失败", "deleted": "Skill 已删除", - "deleteFailed": "删除 Skill 失败" + "deleteFailed": "删除 Skill 失败", + "enabled": "Skill 已启用", + "disabled": "Skill 已禁用", + "toggleFailed": "更新 Skill 可用状态失败" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index f5e8ce93d3..a5ac7cb871 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -544,6 +544,13 @@ "noSelectionHint": "從左側選擇一個 Skill,或點擊「新建 Skill」建立。", "systemBadge": "系統", "systemHint": "CLI 內建 Skill · 唯讀", + "availability": { + "enabled": "已啟用", + "disabled": "已停用", + "toggleAria": "切換 {agent} 的 {skill} 啟用狀態", + "readOnly": "內建 Skill 始終可用。", + "cannotIsolate": "此共享 Skill 無法單獨切換。" + }, "scope": { "global": "全域", "folder": "資料夾", @@ -579,7 +586,10 @@ "created": "Skill 已建立", "saveFailed": "儲存 Skill 失敗", "deleted": "Skill 已刪除", - "deleteFailed": "刪除 Skill 失敗" + "deleteFailed": "刪除 Skill 失敗", + "enabled": "Skill 已啟用", + "disabled": "Skill 已停用", + "toggleFailed": "更新 Skill 可用狀態失敗" }, "templates": { "gemini": "---\nname: example-skill\ndescription: Describe when this skill should be used.\n---\n\n# Skill Name\n\nInstructions for the agent when this skill is active.\n\n## Workflow\n\n1. Add actionable step one.\n2. Add actionable step two.\n", From 7a14a1d7b669d3ee3b014c6371eb19e2be28a1d7 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:07:08 +0800 Subject: [PATCH 15/23] test(git): accept worktree refusal variants --- src-tauri/src/commands/folders.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 6b677c8f63..f06c992b14 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -7783,9 +7783,10 @@ mod tests { let refused = git_delete_branch(repo.clone(), "wt".into(), false) .await .expect_err("git refuses a branch held by a worktree"); + let refused = format!("{refused:?}"); assert!( - format!("{refused:?}").contains("used by worktree"), - "expected git's worktree refusal, got: {refused:?}" + refused.contains("used by worktree") || refused.contains("checked out at"), + "expected git's worktree refusal, got: {refused}" ); // Resolve ours the way git resolves its own — while the directory is From 6ed4140c5ea684a439cd998ec8e5ab3f99481163 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:30:19 +0800 Subject: [PATCH 16/23] fix(skills): harden availability toggles --- .../plans/2026-09-07-agent-skill-switches.md | 91 +++ src-tauri/src/commands/acp.rs | 576 ++++++++++++++++-- 2 files changed, 620 insertions(+), 47 deletions(-) diff --git a/docs/superpowers/plans/2026-09-07-agent-skill-switches.md b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md index c0e885115a..37238abbdb 100644 --- a/docs/superpowers/plans/2026-09-07-agent-skill-switches.md +++ b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md @@ -274,3 +274,94 @@ Expected: each command exits 0 with no failing tests. Run `git diff --check`, inspect `git diff --stat` and `git status --short`, then commit only the task files on `task/1` without merging, rebasing, or pushing `main`. + +### Task 6: Review Fixes For Incoming Links And Cursor Vaults + +**Files:** +- Modify: `src-tauri/src/commands/acp.rs` + +**Interfaces:** +- Produces: a preflight that enumerates every supported entry in peer active + roots and disabled vaults, then rejects a canonical move if an alias or + same-name entry directly links to that canonical Skill +- Produces: a distinct deterministic vault only for Cursor's exact builtin + `~/.cursor/skills-cursor` root, while preserving the legacy vault for + `~/.cursor/skills` and custom roots that happen to use the same basename +- Preserves: `AgentSkillItem.can_toggle` and toggle execution report the same + feasibility + +- [x] **Step 1: Write three failing filesystem regressions** + +Add tests for a shared canonical Skill with an incoming link from a peer that +does not scan the shared root, a private canonical Skill with an incoming peer +link, and Cursor's global writable Skill root: + +```rust +assert!(result.is_err(), "the canonical move must be refused"); +assert!(canonical.join("SKILL.md").is_file()); +assert!(!listed.can_toggle); + +assert_eq!( + disabled_skill_root(&home.join(".cursor/skills")), + home.join(".cursor/.skills.codeg-disabled") +); +assert_ne!( + disabled_skill_root(&home.join(".cursor/skills")), + disabled_skill_root(&home.join(".cursor/skills-cursor")) +); +``` + +- [x] **Step 2: Run focused tests and verify RED** + +Run: + +```bash +cd src-tauri +cargo test --features test-utils incoming_peer_link -- --nocapture +cargo test --features test-utils cursor_global_skill_round_trip -- --nocapture +``` + +Expected: the incoming-link toggles mutate the canonical entry instead of +refusing, and Cursor advertises a toggle that execution rejects because the two +sibling roots resolve to one vault. + +- [x] **Step 3: Implement the minimal preflight and vault compatibility fix** + +Add an entry-level preflight that scans every peer's supported entries in both +active roots and disabled vaults, then uses `skill_link_targets` to identify +direct incoming links. Allow only the exact same-name active links that the +shared restore plan will remove itself; reject aliases, case variants, and +links in a peer's other roots before moving the canonical entry. Invoke the +same preflight from capability calculation and command execution. + +Keep `.cursor/skills` and all unrelated custom roots on their existing +`.skills.codeg-disabled` vaults so disabled entries remain discoverable. Map +only the read-only `.cursor/skills-cursor` root to +`.cursor/.skills-cursor.codeg-disabled` so it no longer claims the writable +root's vault. + +- [x] **Step 4: Run focused tests and verify GREEN** + +Run: + +```bash +cd src-tauri +cargo test --features test-utils incoming_peer_link -- --nocapture +cargo test --features test-utils cursor_global_skill_round_trip -- --nocapture +cargo test --features test-utils skill_capability -- --nocapture +cargo test --features test-utils shared_skill -- --nocapture +``` + +Expected: all focused regressions and existing Skill isolation tests pass. + +- [x] **Step 5: Run the complete repository verification matrix and review** + +Run the frontend checks and every desktop, server, and `codeg-mcp` Rust command +listed in `AGENTS.md`, followed by `git diff --check`. Request an independent +read-only review of the final commit range, resolve all Critical or Important +findings, and commit the fixes on `task/1`. + +The first review found alias/case-variant links outside the exact Skill ID and +custom roots named `skills-cursor` were still unsafe. Those cases now have +RED/GREEN regressions, and the follow-up review reported no remaining Critical +or Important findings. diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 28b1d06141..0d8425cc29 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8397,6 +8397,24 @@ fn skill_content_path(layout: AgentSkillLayout, skill_path: &Path) -> PathBuf { } } +fn skill_entry_layout(path: &Path, kind: SkillStorageKind) -> Option { + if matches!( + kind, + SkillStorageKind::SkillDirectoryOnly | SkillStorageKind::SkillDirectoryOrMarkdownFile + ) && path.is_dir() + && path.join("SKILL.md").is_file() + { + return Some(AgentSkillLayout::SkillDirectory); + } + if kind == SkillStorageKind::SkillDirectoryOrMarkdownFile + && path.is_file() + && is_markdown_file(path) + { + return Some(AgentSkillLayout::MarkdownFile); + } + None +} + /// Symlink-safe removal: if `path` is a symlink (to a file or directory), /// only the link itself is removed. Otherwise directories are removed /// recursively and files are unlinked. This prevents `remove_dir_all` from @@ -8478,46 +8496,40 @@ fn list_skills_from_dir_with_state( let file_name = entry.file_name(); let id = file_name.to_string_lossy().to_string(); - if path.is_dir() - && matches!( - kind, - SkillStorageKind::SkillDirectoryOnly - | SkillStorageKind::SkillDirectoryOrMarkdownFile - ) - { - let skill_doc = path.join("SKILL.md"); - if !skill_doc.is_file() { - continue; + match skill_entry_layout(&path, kind) { + Some(AgentSkillLayout::SkillDirectory) => { + by_id.insert( + id.clone(), + build_skill_item( + id, + scope, + AgentSkillLayout::SkillDirectory, + path, + enabled, + ), + ); } - by_id.insert( - id.clone(), - build_skill_item( - id, - scope, - AgentSkillLayout::SkillDirectory, - path, - enabled, - ), - ); - continue; - } - - if path.is_file() - && matches!(kind, SkillStorageKind::SkillDirectoryOrMarkdownFile) - && is_markdown_file(&path) - { - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .map(str::to_string) - .unwrap_or_else(|| id.clone()); - if by_id.contains_key(&stem) { - continue; + Some(AgentSkillLayout::MarkdownFile) => { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + .unwrap_or_else(|| id.clone()); + if by_id.contains_key(&stem) { + continue; + } + by_id.insert( + stem.clone(), + build_skill_item( + stem, + scope, + AgentSkillLayout::MarkdownFile, + path, + enabled, + ), + ); } - by_id.insert( - stem.clone(), - build_skill_item(stem, scope, AgentSkillLayout::MarkdownFile, path, enabled), - ); + None => {} } } @@ -8525,10 +8537,17 @@ fn list_skills_from_dir_with_state( } pub(crate) fn disabled_skill_root(active_root: &Path) -> PathBuf { + let cursor_builtin_root = home_dir_or_default().join(".cursor").join("skills-cursor"); + let vault_name = if active_root == cursor_builtin_root { + ".skills-cursor.codeg-disabled" + } else { + // Keep the original location for existing disabled entries. + ".skills.codeg-disabled" + }; active_root .parent() .unwrap_or_else(|| Path::new("")) - .join(".skills.codeg-disabled") + .join(vault_name) } pub(crate) fn list_skills_from_roots( @@ -9042,6 +9061,74 @@ fn shared_skill_affected_peers<'a>( Ok(affected) } +fn preflight_unplanned_incoming_skill_links( + selected: &SkillPeer, + peers: &[SkillPeer], + root: &Path, + skill_id: &str, + layout: AgentSkillLayout, + canonical: &Path, +) -> Result<(), AcpError> { + let resolved_root = resolved_skill_root(root)?; + let expected_name = match layout { + AgentSkillLayout::SkillDirectory => skill_id.to_string(), + AgentSkillLayout::MarkdownFile => format!("{skill_id}.md"), + }; + for peer in peers { + let is_selected = peer.agent == selected.agent && peer.scope == selected.scope; + let shares_canonical_root = + peer.roots.iter().try_fold(false, |shares, peer_root| { + Ok::<_, AcpError>( + shares || resolved_root == resolved_skill_root(peer_root)?, + ) + })?; + for peer_root in &peer.roots { + let vault = disabled_skill_root(peer_root); + for (scan, active) in [(peer_root.as_path(), true), (vault.as_path(), false)] { + let entries = match fs::read_dir(scan) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(AcpError::protocol(format!( + "failed to inspect peer skill root '{}': {error}", + scan.display() + ))) + } + }; + for entry in entries { + let entry = entry + .map_err(|error| { + AcpError::protocol(format!( + "failed to inspect peer skill entry in '{}': {error}", + scan.display() + )) + })? + .path(); + if !skill_link_targets(&entry, canonical) + || skill_entry_layout(&entry, peer.kind).is_none() + { + continue; + } + let planned_restore_link = !is_selected + && shares_canonical_root + && active + && entry.file_name().and_then(|name| name.to_str()) + == Some(expected_name.as_str()); + if planned_restore_link { + continue; + } + return Err(AcpError::protocol(format!( + "skill '{skill_id}' has an incoming peer link '{}' from {}; moving its canonical entry is unsupported", + entry.display(), + peer.agent + ))); + } + } + } + } + Ok(()) +} + fn shared_skill_restore_links( affected: &[&SkillPeer], id: &str, @@ -9077,6 +9164,14 @@ fn plan_shared_skill_fanout<'a>( root: &Path, skill: &AgentSkillItem, ) -> Result, AcpError> { + preflight_unplanned_incoming_skill_links( + selected, + peers, + root, + &skill.id, + skill.layout, + Path::new(&skill.path), + )?; let vault = disabled_skill_root(root); preflight_skill_destination(&vault, &skill.id)?; for scan in &selected.roots { @@ -9124,6 +9219,15 @@ fn listed_skill_can_toggle( }) .ok_or_else(|| AcpError::protocol("listed skill has no owning native root"))?; if !skill_root_is_shared_with_peers(selected.agent, selected.scope, root, peers)? { + preflight_unplanned_incoming_skill_links( + selected, + peers, + root, + &skill.id, + skill.layout, + Path::new(&skill.path), + )?; + preflight_disabled_skill_root_with_peers(root, peers)?; return Ok(true); } preflight_shared_skill_owner(root, peers)?; @@ -9144,6 +9248,14 @@ fn listed_skill_can_toggle( } preflight_skill_destination(root, &skill.id)?; preflight_skill_symlink_move(Path::new(&skill.path), root)?; + preflight_unplanned_incoming_skill_links( + selected, + peers, + root, + &skill.id, + skill.layout, + Path::new(&skill.path), + )?; let affected = shared_skill_affected_peers(selected, peers, root, skill.layout)?; shared_skill_restore_links(&affected, &skill.id, Path::new(&skill.path))?; Ok(true) @@ -9347,6 +9459,14 @@ fn set_shared_skill_enabled( None => { preflight_skill_destination(root, &id)?; preflight_skill_symlink_move(&canonical, root)?; + preflight_unplanned_incoming_skill_links( + selected, + peers, + root, + &id, + canonical_item.layout, + &canonical, + )?; affected = shared_skill_affected_peers(selected, peers, root, canonical_item.layout)?; redundant_links = shared_skill_restore_links(&affected, &id, &canonical)?; @@ -13694,8 +13814,15 @@ pub async fn acp_set_agent_skill_enabled( ))); } reject_multiple_active_skills(&dirs, spec.kind, &id)?; + let peers = skill_peers(workspace_path.as_deref()); + let selected = SkillPeer { + agent: agent_type, + scope, + kind: spec.kind, + roots: dirs.clone(), + }; for candidate in &dirs { - if !skill_root_is_shared(agent_type, scope, workspace_path.as_deref(), candidate)? { + if !skill_root_is_shared_with_peers(agent_type, scope, candidate, &peers)? { continue; } let canonical = locate_existing_skill( @@ -13710,13 +13837,6 @@ pub async fn acp_set_agent_skill_enabled( skill_link_targets(Path::new(&skill.path), Path::new(&item.path)) }) { - let peers = skill_peers(workspace_path.as_deref()); - let selected = SkillPeer { - agent: agent_type, - scope, - kind: spec.kind, - roots: dirs.clone(), - }; return set_shared_skill_enabled( &selected, &peers, @@ -13728,6 +13848,14 @@ pub async fn acp_set_agent_skill_enabled( } } if skill.enabled != enabled { + preflight_unplanned_incoming_skill_links( + &selected, + &peers, + root, + &id, + skill.layout, + Path::new(&skill.path), + )?; preflight_disabled_skill_root(root, workspace_path.as_deref())?; } let moved = set_private_skill_enabled(root, spec.kind, scope, &id, enabled)?; @@ -17021,6 +17149,360 @@ wire_api = "chat" (shared, peers) } + #[cfg(unix)] + #[test] + fn shared_skill_incoming_peer_link_is_rejected_before_canonical_move() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + let incoming_root = tmp.path().join("incoming/skills"); + fs::create_dir_all(&incoming_root).unwrap(); + std::os::unix::fs::symlink(shared.join("demo"), incoming_root.join("demo")).unwrap(); + peers.push(SkillPeer { + agent: AgentType::OpenCode, + scope: AgentSkillScope::Project, + kind: SkillStorageKind::SkillDirectoryOnly, + roots: vec![incoming_root.clone()], + }); + let listed = locate_existing_skill_across_dirs( + &peers[1].roots, + peers[1].kind, + "demo", + peers[1].scope, + ) + .unwrap(); + + let can_toggle = listed_skill_can_toggle(&peers[1], &peers, &listed).unwrap_or(false); + let result = set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "demo", + false, + create_skill_link, + ); + + assert!(!can_toggle, "capability must match the move preflight"); + assert!( + result + .unwrap_err() + .to_string() + .contains("incoming peer link"), + "the canonical move must be refused" + ); + assert_eq!( + fs::read_to_string(shared.join("demo/SKILL.md")).unwrap(), + "shared" + ); + assert_eq!( + fs::read_to_string(incoming_root.join("demo/SKILL.md")).unwrap(), + "shared" + ); + assert!(!disabled_skill_root(&shared).exists()); + assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); + } + + #[cfg(unix)] + #[test] + fn shared_skill_alias_incoming_peer_link_is_rejected_before_canonical_move() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, mut peers) = shared_skill_fixture(tmp.path()); + let incoming_root = tmp.path().join("incoming/skills"); + fs::create_dir_all(&incoming_root).unwrap(); + std::os::unix::fs::symlink(shared.join("demo"), incoming_root.join("alias")).unwrap(); + peers.push(SkillPeer { + agent: AgentType::OpenCode, + scope: AgentSkillScope::Project, + kind: SkillStorageKind::SkillDirectoryOnly, + roots: vec![incoming_root.clone()], + }); + let listed = locate_existing_skill_across_dirs( + &peers[1].roots, + peers[1].kind, + "demo", + peers[1].scope, + ) + .unwrap(); + + let can_toggle = listed_skill_can_toggle(&peers[1], &peers, &listed).unwrap_or(false); + let result = set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "demo", + false, + create_skill_link, + ); + + assert!(!can_toggle, "aliases must be included in the move preflight"); + assert!(result + .unwrap_err() + .to_string() + .contains("incoming peer link")); + assert_eq!( + fs::read_to_string(shared.join("demo/SKILL.md")).unwrap(), + "shared" + ); + assert_eq!( + fs::read_to_string(incoming_root.join("alias/SKILL.md")).unwrap(), + "shared" + ); + assert!(!disabled_skill_root(&shared).exists()); + } + + #[cfg(unix)] + #[test] + fn shared_markdown_skill_alias_case_variant_incoming_link_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let shared = tmp.path().join("shared/skills"); + let incoming_root = tmp.path().join("incoming/skills"); + fs::create_dir_all(&shared).unwrap(); + fs::create_dir_all(&incoming_root).unwrap(); + fs::write(shared.join("demo.md"), "shared markdown").unwrap(); + std::os::unix::fs::symlink(shared.join("demo.md"), incoming_root.join("alias.MD")) + .unwrap(); + let peers = vec![ + SkillPeer { + agent: AgentType::Codex, + scope: AgentSkillScope::Project, + kind: SkillStorageKind::SkillDirectoryOrMarkdownFile, + roots: vec![tmp.path().join("owner/skills"), shared.clone()], + }, + SkillPeer { + agent: AgentType::OpenCode, + scope: AgentSkillScope::Project, + kind: SkillStorageKind::SkillDirectoryOrMarkdownFile, + roots: vec![incoming_root.clone()], + }, + ]; + let listed = locate_existing_skill_across_dirs( + &peers[0].roots, + peers[0].kind, + "demo", + peers[0].scope, + ) + .unwrap(); + + let can_toggle = listed_skill_can_toggle(&peers[0], &peers, &listed).unwrap_or(false); + let result = set_shared_skill_enabled( + &peers[0], + &peers, + &shared, + "demo", + false, + create_skill_link, + ); + + assert!(!can_toggle, "case variants must be included in the preflight"); + assert!(result + .unwrap_err() + .to_string() + .contains("incoming peer link")); + assert_eq!(fs::read_to_string(shared.join("demo.md")).unwrap(), "shared markdown"); + assert_eq!( + fs::read_to_string(incoming_root.join("alias.MD")).unwrap(), + "shared markdown" + ); + assert!(!disabled_skill_root(&shared).exists()); + } + + #[cfg(unix)] + #[test] + fn shared_peer_secondary_root_incoming_alias_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let (shared, peers) = shared_skill_fixture(tmp.path()); + fs::create_dir_all(&peers[0].roots[0]).unwrap(); + std::os::unix::fs::symlink( + shared.join("demo"), + peers[0].roots[0].join("alias"), + ) + .unwrap(); + let listed = locate_existing_skill_across_dirs( + &peers[1].roots, + peers[1].kind, + "demo", + peers[1].scope, + ) + .unwrap(); + + let can_toggle = listed_skill_can_toggle(&peers[1], &peers, &listed).unwrap_or(false); + let result = set_shared_skill_enabled( + &peers[1], + &peers, + &shared, + "demo", + false, + create_skill_link, + ); + + assert!( + !can_toggle, + "only the peer's shared root may be skipped by the preflight" + ); + assert!(result + .unwrap_err() + .to_string() + .contains("incoming peer link")); + assert_eq!( + fs::read_to_string(shared.join("demo/SKILL.md")).unwrap(), + "shared" + ); + assert_eq!( + fs::read_to_string(peers[0].roots[0].join("alias/SKILL.md")).unwrap(), + "shared" + ); + assert!(!disabled_skill_root(&shared).exists()); + } + + #[cfg(unix)] + #[test] + fn private_skill_incoming_peer_link_is_rejected_before_canonical_move() { + use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; + let _registry_guard = hydrate_test_guard(); + let tmp = tempfile::tempdir().unwrap(); + let canonical_root = tmp.path().join("owner/skills"); + let incoming_root = tmp.path().join("peer/skills"); + fs::create_dir_all(canonical_root.join("demo")).unwrap(); + fs::write(canonical_root.join("demo/SKILL.md"), "private").unwrap(); + fs::create_dir_all(&incoming_root).unwrap(); + std::os::unix::fs::symlink( + canonical_root.join("demo"), + incoming_root.join("demo"), + ) + .unwrap(); + assert!(hydrate(&[ + shared_skill_custom_def("task6-private-owner", &canonical_root), + shared_skill_custom_def("task6-private-peer", &incoming_root), + ]) + .is_empty()); + let owner = AgentType::custom("task6-private-owner").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let listed = runtime + .block_on(acp_list_agent_skills(owner, None)) + .unwrap() + .skills + .into_iter() + .find(|item| item.id == "demo") + .unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled( + owner, + AgentSkillScope::Global, + "demo".into(), + None, + false, + )); + hydrate(&[]); + + assert!(!listed.can_toggle, "capability must match command execution"); + assert!(result + .unwrap_err() + .to_string() + .contains("incoming peer link")); + assert_eq!( + fs::read_to_string(canonical_root.join("demo/SKILL.md")).unwrap(), + "private" + ); + assert_eq!( + fs::read_to_string(incoming_root.join("demo/SKILL.md")).unwrap(), + "private" + ); + assert!(!disabled_skill_root(&canonical_root).exists()); + } + + #[cfg(unix)] + #[test] + fn cursor_global_skill_round_trip_keeps_legacy_vault_compatible() { + let tmp = tempfile::tempdir().unwrap(); + temp_env::with_vars( + [ + ("HOME", Some(tmp.path())), + ("CODEX_HOME", None::<&Path>), + ("GEMINI_HOME", None), + ("HERMES_HOME", None), + ("KIMI_CODE_HOME", None), + ("PI_CODING_AGENT_DIR", None), + ("DSH_HOME", None), + ("DSH_AGENTS_HOME", None), + ("QODER_CONFIG_DIR", None), + ("QODER_CLI_HOME", None), + ], + || { + let home = home_dir_or_default(); + let writable_root = home.join(".cursor/skills"); + let builtin_root = home.join(".cursor/skills-cursor"); + let legacy_vault = home.join(".cursor/.skills.codeg-disabled"); + fs::create_dir_all(writable_root.join("demo")).unwrap(); + fs::write(writable_root.join("demo/SKILL.md"), "cursor").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let listed = runtime + .block_on(acp_list_agent_skills(AgentType::Cursor, None)) + .unwrap() + .skills + .into_iter() + .find(|item| item.id == "demo") + .unwrap(); + let disabled = runtime.block_on(acp_set_agent_skill_enabled( + AgentType::Cursor, + AgentSkillScope::Global, + "demo".into(), + None, + false, + )); + + assert!(listed.can_toggle); + assert_eq!(disabled_skill_root(&writable_root), legacy_vault); + assert_ne!( + disabled_skill_root(&writable_root), + disabled_skill_root(&builtin_root) + ); + let disabled = disabled.expect("Cursor global skill must disable"); + assert!(!disabled.enabled); + assert!(legacy_vault.join("demo/SKILL.md").is_file()); + let enabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Cursor, + AgentSkillScope::Global, + "demo".into(), + None, + true, + )) + .expect("Cursor global skill must re-enable"); + assert!(enabled.enabled); + assert!(writable_root.join("demo/SKILL.md").is_file()); + assert!(!legacy_vault.join("demo").exists()); + }, + ); + } + + #[test] + fn custom_skill_root_named_skills_cursor_keeps_legacy_disabled_entries() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("custom/skills-cursor"); + let legacy_vault = root.parent().unwrap().join(".skills.codeg-disabled"); + fs::create_dir_all(legacy_vault.join("demo")).unwrap(); + fs::write(legacy_vault.join("demo/SKILL.md"), "legacy").unwrap(); + + let listed = list_skills_from_roots( + AgentSkillScope::Global, + std::slice::from_ref(&root), + SkillStorageKind::SkillDirectoryOnly, + ) + .unwrap(); + + assert_eq!(disabled_skill_root(&root), legacy_vault); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "demo"); + assert!(!listed[0].enabled); + } + + #[test] + fn cursor_builtin_skill_root_uses_distinct_vault() { + let root = home_dir_or_default().join(".cursor/skills-cursor"); + assert_eq!( + disabled_skill_root(&root), + home_dir_or_default().join(".cursor/.skills-cursor.codeg-disabled") + ); + } + fn skill_capability_project_fixture(base: &Path) -> PathBuf { let shared = base.join(".claude/skills"); fs::create_dir_all(shared.join("capability-demo")).unwrap(); From 7d838850a720f1775c89b331640fc8edda1907d2 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:37:38 +0800 Subject: [PATCH 17/23] fix(skills): use native codex availability config --- src-tauri/src/commands/acp.rs | 1283 ++++++++++++++--- .../settings/skills-settings.test.tsx | 14 + src/components/settings/skills-settings.tsx | 14 +- 3 files changed, 1109 insertions(+), 202 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 0d8425cc29..edca6139f0 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -2675,6 +2676,14 @@ fn codex_config_toml_path() -> PathBuf { codex_home_dir().join("config.toml") } +static CODEX_CONFIG_MUTATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn lock_codex_config_mutation() -> Result, AcpError> { + CODEX_CONFIG_MUTATION_LOCK + .lock() + .map_err(|_| AcpError::protocol("codex config mutation lock poisoned")) +} + fn codex_auth_json_path() -> PathBuf { codex_home_dir().join("auth.json") } @@ -3320,6 +3329,7 @@ fn persist_codex_local_config(config_patch_json: Option<&str>) -> Result<(), Acp model, env, } = runtime; + let _config_guard = lock_codex_config_mutation()?; let config_path = codex_config_toml_path(); let mut toml_value = if config_path.exists() { @@ -3412,15 +3422,11 @@ fn persist_codex_local_config(config_patch_json: Option<&str>) -> Result<(), Acp } } - let serialized_toml = toml::to_string_pretty(&toml_value) - .map_err(|e| AcpError::protocol(format!("serialize codex toml failed: {e}")))?; - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - AcpError::protocol(format!("create codex config directory failed: {e}")) - })?; - } - fs::write(&config_path, format!("{serialized_toml}\n")) - .map_err(|e| AcpError::protocol(format!("write codex config failed: {e}")))?; + let serialized_toml = format!( + "{}\n", + toml::to_string_pretty(&toml_value) + .map_err(|e| AcpError::protocol(format!("serialize codex toml failed: {e}")))? + ); let auth_path = codex_auth_json_path(); let mut auth_value = if auth_path.exists() { @@ -3448,34 +3454,58 @@ fn persist_codex_local_config(config_patch_json: Option<&str>) -> Result<(), Acp auth_obj.remove("OPENAI_API_KEY"); } } - let serialized_auth = serde_json::to_string_pretty(&auth_value) - .map_err(|e| AcpError::protocol(format!("serialize codex auth failed: {e}")))?; - if let Some(parent) = auth_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| AcpError::protocol(format!("create codex auth directory failed: {e}")))?; + let serialized_auth = format!( + "{}\n", + serde_json::to_string_pretty(&auth_value) + .map_err(|e| AcpError::protocol(format!("serialize codex auth failed: {e}")))? + ); + + persist_codex_native_config_files_unlocked(Some(&serialized_auth), Some(&serialized_toml)) +} + +fn codex_atomic_write_target(path: &Path) -> std::io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => fs::canonicalize(path), + Ok(_) => Ok(path.to_path_buf()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(error) => Err(error), + } +} + +fn write_codex_file_atomic(path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; } - fs::write(&auth_path, format!("{serialized_auth}\n")) - .map_err(|e| AcpError::protocol(format!("write codex auth failed: {e}")))?; + let target = codex_atomic_write_target(path)?; + let parent = target.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("codex file has no parent: {}", target.display()), + ) + })?; + fs::create_dir_all(parent)?; + let permissions = fs::metadata(&target) + .ok() + .map(|metadata| metadata.permissions()); + let mut staged = tempfile::NamedTempFile::new_in(parent)?; + staged.write_all(contents.as_bytes())?; + if let Some(permissions) = permissions { + staged.as_file().set_permissions(permissions)?; + } + staged.as_file_mut().sync_all()?; + staged.persist(&target).map_err(|error| error.error)?; Ok(()) } -fn persist_codex_native_config_files( +fn persist_codex_native_config_files_unlocked( codex_auth_json: Option<&str>, codex_config_toml: Option<&str>, ) -> Result<(), AcpError> { if let Some(raw_toml) = codex_config_toml { toml::from_str::(raw_toml) .map_err(|e| AcpError::protocol(format!("invalid codex config.toml: {e}")))?; - let path = codex_config_toml_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| AcpError::protocol(format!("create codex directory failed: {e}")))?; - } - fs::write(&path, raw_toml) - .map_err(|e| AcpError::protocol(format!("write codex config.toml failed: {e}")))?; } - if let Some(raw_auth) = codex_auth_json { let parsed = serde_json::from_str::(raw_auth) .map_err(|e| AcpError::protocol(format!("invalid codex auth.json: {e}")))?; @@ -3484,18 +3514,31 @@ fn persist_codex_native_config_files( "invalid codex auth.json: root must be a JSON object", )); } + } + + if let Some(raw_toml) = codex_config_toml { + let path = codex_config_toml_path(); + write_codex_file_atomic(&path, raw_toml) + .map_err(|e| AcpError::protocol(format!("write codex config.toml failed: {e}")))?; + } + + if let Some(raw_auth) = codex_auth_json { let path = codex_auth_json_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| AcpError::protocol(format!("create codex directory failed: {e}")))?; - } - fs::write(&path, raw_auth) + write_codex_file_atomic(&path, raw_auth) .map_err(|e| AcpError::protocol(format!("write codex auth.json failed: {e}")))?; } Ok(()) } +fn persist_codex_native_config_files( + codex_auth_json: Option<&str>, + codex_config_toml: Option<&str>, +) -> Result<(), AcpError> { + let _config_guard = lock_codex_config_mutation()?; + persist_codex_native_config_files_unlocked(codex_auth_json, codex_config_toml) +} + /// Read `~/.codex/config.toml` as the base of a structured sandbox merge. /// A missing file is an empty base; a real read error fails loudly so a save can /// never silently drop the user's existing config. @@ -3672,14 +3715,20 @@ fn remove_codex_catalog_key( /// Drop codeg's own `model_catalog_json` reference from the config.toml on disk, /// if it carries one. Reads fresh so it also cleans up a key written by an /// earlier codeg version or by another window since the panel opened. -fn drop_codex_catalog_reference() -> Result<(), AcpError> { +fn drop_codex_catalog_reference_unlocked() -> Result<(), AcpError> { let base = read_codex_config_or_empty()?; if let Some(next) = remove_codex_catalog_key(&base, &codex_home_dir())? { - persist_codex_native_config_files(None, Some(&next))?; + persist_codex_native_config_files_unlocked(None, Some(&next))?; } Ok(()) } +#[cfg(test)] +fn drop_codex_catalog_reference() -> Result<(), AcpError> { + let _config_guard = lock_codex_config_mutation()?; + drop_codex_catalog_reference_unlocked() +} + /// Apply the Codex panel's sandbox / approval PATCH to the raw config.toml text, /// format-preservingly (comments and unmanaged keys are kept). Values are /// validated against the upstream vocabularies first, so a UI bug can never @@ -8634,6 +8683,359 @@ pub(crate) fn locate_existing_skill_across_dirs( None } +fn active_skill_entries( + roots: &[PathBuf], + kind: SkillStorageKind, + skill_id: &str, + scope: AgentSkillScope, +) -> Result, AcpError> { + let mut matches = Vec::new(); + let mut seen_roots = std::collections::HashSet::new(); + for root in roots { + if !seen_roots.insert(resolved_skill_root(root)?) { + continue; + } + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(AcpError::protocol(format!( + "failed to inspect active skills directory '{}': {error}", + root.display() + ))) + } + }; + for entry in entries { + let path = entry + .map_err(|error| { + AcpError::protocol(format!("failed to inspect active skill: {error}")) + })? + .path(); + let Some(layout) = skill_entry_layout(&path, kind) else { + continue; + }; + let id = match layout { + AgentSkillLayout::SkillDirectory => path.file_name(), + AgentSkillLayout::MarkdownFile => path.file_stem(), + } + .and_then(|name| name.to_str()); + if id == Some(skill_id) { + matches.push(build_skill_item( + skill_id.to_string(), + scope, + layout, + path, + true, + )); + } + } + } + Ok(matches) +} + +#[derive(Debug, Default)] +struct CodexSkillConfig { + entries: Vec, +} + +#[derive(Debug)] +struct CodexSkillConfigEntry { + path: Option, + name: Option, + enabled: bool, +} + +fn resolve_codex_skill_config_path(raw: &str, codex_home: &Path) -> PathBuf { + if raw == "~" { + return home_dir_or_default(); + } + if let Some(relative) = raw.strip_prefix("~/") { + return home_dir_or_default().join(relative); + } + let path = PathBuf::from(raw); + if path.is_absolute() { + path + } else { + codex_home.join(path) + } +} + +fn absolute_skill_content_path(skill: &AgentSkillItem) -> Result { + let path = skill_content_path(skill.layout, Path::new(&skill.path)); + if path.is_absolute() { + Ok(path) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|error| { + AcpError::protocol(format!("failed to resolve current directory: {error}")) + }) + } +} + +fn same_skill_config_path(first: &Path, second: &Path) -> bool { + first == second + || fs::canonicalize(first) + .ok() + .zip(fs::canonicalize(second).ok()) + .is_some_and(|(first, second)| first == second) +} + +fn parse_codex_skill_config( + raw_toml: &str, + codex_home: &Path, +) -> Result { + let root = raw_toml + .parse::() + .map_err(|error| AcpError::protocol(format!("invalid codex config.toml: {error}")))?; + let Some(config) = root + .get("skills") + .and_then(toml::Value::as_table) + .and_then(|skills| skills.get("config")) + else { + return Ok(CodexSkillConfig::default()); + }; + let config = config.as_array().ok_or_else(|| { + AcpError::protocol("invalid codex config.toml: skills.config must be an array") + })?; + let mut entries = Vec::with_capacity(config.len()); + for entry in config { + let table = entry.as_table().ok_or_else(|| { + AcpError::protocol("invalid codex config.toml: skills.config entry must be a table") + })?; + let path = match table.get("path") { + Some(value) => Some( + value + .as_str() + .map(|path| resolve_codex_skill_config_path(path, codex_home)) + .ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config path must be a string", + ) + })?, + ), + None => None, + }; + let name = match table.get("name") { + Some(value) => Some(value.as_str().map(str::to_string).ok_or_else(|| { + AcpError::protocol("invalid codex config.toml: skills.config name must be a string") + })?), + None => None, + }; + let enabled = table + .get("enabled") + .ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config enabled is required", + ) + })? + .as_bool() + .ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config enabled must be a boolean", + ) + })?; + entries.push(CodexSkillConfigEntry { + path, + name, + enabled, + }); + } + Ok(CodexSkillConfig { entries }) +} + +impl CodexSkillConfig { + fn skill_enabled(&self, path: &Path, name: &str) -> bool { + if let Some(enabled) = self + .entries + .iter() + .filter(|entry| { + entry + .path + .as_deref() + .is_some_and(|configured| same_skill_config_path(configured, path)) + }) + .map(|entry| entry.enabled) + .next_back() + { + return enabled; + } + + self.entries + .iter() + .filter(|entry| entry.name.as_deref() == Some(name)) + .map(|entry| entry.enabled) + .next_back() + .unwrap_or(true) + } +} + +fn codex_skill_entries_enabled( + entries: &[AgentSkillItem], + config: &CodexSkillConfig, +) -> Result { + for skill in entries { + if config.skill_enabled(&absolute_skill_content_path(skill)?, &skill.id) { + return Ok(true); + } + } + Ok(false) +} + +fn apply_codex_skill_enabled_config( + base_toml: &str, + codex_home: &Path, + paths: &[PathBuf], + enabled: bool, +) -> Result { + parse_codex_skill_config(base_toml, codex_home)?; + let mut doc = base_toml + .parse::() + .map_err(|error| AcpError::protocol(format!("invalid codex config.toml: {error}")))?; + if doc.get("skills").is_none() { + doc["skills"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + let skills = doc + .get_mut("skills") + .and_then(toml_edit::Item::as_table_mut) + .ok_or_else(|| AcpError::protocol("invalid codex config.toml: skills must be a table"))?; + if skills.get("config").is_none() { + skills.insert( + "config", + toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()), + ); + } + // Match Codex's native `skills/config/write`: persist the resolved + // SKILL.md target so symlink aliases collapse to one stable rule. + let mut unique_paths = paths + .iter() + .map(|path| fs::canonicalize(path).unwrap_or_else(|_| path.clone())) + .collect::>(); + unique_paths.sort(); + unique_paths.dedup(); + + let config = skills + .get_mut("config") + .ok_or_else(|| AcpError::protocol("invalid codex config.toml: missing skills.config"))?; + match config { + toml_edit::Item::ArrayOfTables(config) => { + for path in unique_paths { + let mut matched = false; + for entry in config.iter_mut() { + let path_matches = entry + .get("path") + .and_then(toml_edit::Item::as_str) + .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) + .is_some_and(|configured| same_skill_config_path(&configured, &path)); + if path_matches { + entry.insert("enabled", toml_edit::value(enabled)); + matched = true; + } + } + if !matched { + let mut entry = toml_edit::Table::new(); + entry.insert("path", toml_edit::value(path.to_string_lossy().as_ref())); + entry.insert("enabled", toml_edit::value(enabled)); + config.push(entry); + } + } + } + toml_edit::Item::Value(toml_edit::Value::Array(config)) => { + for path in unique_paths { + let mut matched = false; + for value in config.iter_mut() { + let entry = value.as_inline_table_mut().ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config entry must be a table", + ) + })?; + let path_matches = entry + .get("path") + .and_then(toml_edit::Value::as_str) + .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) + .is_some_and(|configured| same_skill_config_path(&configured, &path)); + if path_matches { + entry.insert("enabled", toml_edit::Value::from(enabled)); + matched = true; + } + } + if !matched { + let mut entry = toml_edit::InlineTable::new(); + entry.insert( + "path", + toml_edit::Value::from(path.to_string_lossy().as_ref()), + ); + entry.insert("enabled", toml_edit::Value::from(enabled)); + config.push(toml_edit::Value::InlineTable(entry)); + } + } + } + _ => { + return Err(AcpError::protocol( + "invalid codex config.toml: skills.config must be an array of tables", + )) + } + } + Ok(doc.to_string()) +} + +fn set_codex_skill_enabled_native( + mut listed: AgentSkillItem, + active: &[AgentSkillItem], + enabled: bool, +) -> Result { + let _config_guard = lock_codex_config_mutation()?; + let codex_home = codex_home_dir(); + let base = read_codex_config_or_empty()?; + let current = parse_codex_skill_config(&base, &codex_home)?; + listed.enabled = codex_skill_entries_enabled(active, ¤t)?; + listed.can_toggle = true; + if listed.enabled == enabled { + return Ok(listed); + } + let paths = active + .iter() + .map(absolute_skill_content_path) + .collect::, _>>()?; + let next = apply_codex_skill_enabled_config(&base, &codex_home, &paths, enabled)?; + let updated = parse_codex_skill_config(&next, &codex_home)?; + if codex_skill_entries_enabled(active, &updated)? != enabled { + return Err(AcpError::protocol( + "requested Codex skill state was not reached", + )); + } + persist_codex_native_config_files_unlocked(None, Some(&next))?; + listed.enabled = enabled; + Ok(listed) +} + +fn apply_codex_native_skill_state( + agent_type: AgentType, + roots: &[PathBuf], + kind: SkillStorageKind, + skill: &mut AgentSkillItem, +) { + if agent_type != AgentType::Codex || !skill.enabled { + return; + } + let state = active_skill_entries(roots, kind, &skill.id, skill.scope).and_then(|active| { + if active.is_empty() { + return Err(AcpError::protocol("active Codex skill entry not found")); + } + let codex_home = codex_home_dir(); + let raw = read_codex_config_or_empty()?; + let config = parse_codex_skill_config(&raw, &codex_home)?; + codex_skill_entries_enabled(&active, &config) + }); + match state { + Ok(enabled) => { + skill.enabled = enabled; + skill.can_toggle = true; + } + Err(_) => skill.can_toggle = false, + } +} + // All settings mutations share this lock so lookup, preflight and mutation // observe one state. No guard is held across an await. static SKILL_MUTATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -10678,6 +11080,7 @@ fn cascade_update_agent_config( // model-provider endpoint to cascade into. } AgentType::Codex => { + let _config_guard = lock_codex_config_mutation()?; let auth_path = codex_auth_json_path(); let mut auth_obj = if auth_path.exists() { fs::read_to_string(&auth_path) @@ -10789,7 +11192,7 @@ fn cascade_update_agent_config( let toml_str = toml::to_string_pretty(&toml_value) .map_err(|e| AcpError::protocol(e.to_string()))?; - persist_codex_native_config_files(Some(&auth_str), Some(&toml_str))?; + persist_codex_native_config_files_unlocked(Some(&auth_str), Some(&toml_str))?; } AgentType::OpenCode => { let auth_path = opencode_auth_json_path(); @@ -12190,6 +12593,7 @@ fn apply_codex_root_model_action(action: &CodexModelAction) -> Result<(), AcpErr if matches!(action, CodexModelAction::NoOp) { return Ok(()); } + let _config_guard = lock_codex_config_mutation()?; let config_path = codex_config_toml_path(); let mut toml_value = if config_path.exists() { fs::read_to_string(&config_path) @@ -12214,7 +12618,7 @@ fn apply_codex_root_model_action(action: &CodexModelAction) -> Result<(), AcpErr } let toml_str = toml::to_string_pretty(&toml_value).map_err(|e| AcpError::protocol(e.to_string()))?; - persist_codex_native_config_files(None, Some(&toml_str))?; + persist_codex_native_config_files_unlocked(None, Some(&toml_str))?; Ok(()) } @@ -12229,6 +12633,7 @@ fn apply_codex_root_model_action(action: &CodexModelAction) -> Result<(), AcpErr /// asks the backend to (re)write the catalog *files* (see /// `acp_update_agent_config_core`). fn apply_codex_catalog_and_model(raw: Option<&str>) -> Result<(), AcpError> { + let _config_guard = lock_codex_config_mutation()?; let snapshot = crate::acp::codex_catalog_source::cached_or_bundled_snapshot(); let injection = crate::acp::codex_model_catalog::write_catalog_files( raw.unwrap_or_default(), @@ -12272,7 +12677,7 @@ fn apply_codex_catalog_and_model(raw: Option<&str>) -> Result<(), AcpError> { } let toml_str = toml::to_string_pretty(&toml_value).map_err(|e| AcpError::protocol(e.to_string()))?; - persist_codex_native_config_files(None, Some(&toml_str))?; + persist_codex_native_config_files_unlocked(None, Some(&toml_str))?; Ok(()) } @@ -12372,6 +12777,7 @@ pub(crate) async fn acp_update_agent_config_core( } if agent_type == AgentType::Codex { + let _config_guard = lock_codex_config_mutation()?; // Mirrors the Grok/Cursor flow. The advanced raw editor sends the whole // file (`codex_config_toml = Some(text)`), so that text is the verbatim // base; the sandbox/approval controls send only a patch, merged onto the @@ -12389,7 +12795,10 @@ pub(crate) async fn acp_update_agent_config_core( } None => codex_config_toml, }; - persist_codex_native_config_files(codex_auth_json.as_deref(), merged_toml.as_deref())?; + persist_codex_native_config_files_unlocked( + codex_auth_json.as_deref(), + merged_toml.as_deref(), + )?; } // The frontend has already patched config.toml's `model_catalog_json` + // root `model` into `codex_config_toml` (comment-preserving text patch); @@ -12407,7 +12816,7 @@ pub(crate) async fn acp_update_agent_config_core( // The frontend only patches that key when the user *edits* the // model editor, so a save that merely lets a stale removal // dissolve would otherwise leave the two out of sync. - Ok(None) => drop_codex_catalog_reference()?, + Ok(None) => drop_codex_catalog_reference_unlocked()?, Ok(Some(_)) => {} Err(e) => { tracing::error!("[acp_update_agent_config] write codex catalog failed: {e}") @@ -13751,8 +14160,29 @@ pub async fn acp_list_agent_skills( let mut skills = skills_by_key.into_values().collect::>(); let peers = skill_peers(workspace_path.as_deref()); + let codex_skill_config = if agent_type == AgentType::Codex { + let codex_home = codex_home_dir(); + Some( + read_codex_config_or_empty() + .and_then(|raw| parse_codex_skill_config(&raw, &codex_home)), + ) + } else { + None + }; for skill in &mut skills { apply_skill_capabilities(agent_type, skill); + if agent_type == AgentType::Codex && skill.enabled { + let active = scoped_skill_dirs(agent_type, skill.scope, workspace_path.as_deref()) + .and_then(|roots| active_skill_entries(&roots, spec.kind, &skill.id, skill.scope)); + match (active, codex_skill_config.as_ref()) { + (Ok(active), Some(Ok(config))) if !active.is_empty() => { + skill.enabled = codex_skill_entries_enabled(&active, config)?; + skill.can_toggle = true; + } + _ => skill.can_toggle = false, + } + continue; + } if skill.can_toggle { skill.can_toggle = peers .iter() @@ -13797,6 +14227,10 @@ pub async fn acp_set_agent_skill_enabled( let mut skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; apply_skill_capabilities(agent_type, &mut skill); + if agent_type == AgentType::Codex && skill.enabled { + let active = active_skill_entries(&dirs, spec.kind, &id, scope)?; + return set_codex_skill_enabled_native(skill, &active, enabled); + } let parent = Path::new(&skill.path).parent(); let root = dirs .iter() @@ -13883,6 +14317,7 @@ pub async fn acp_read_agent_skill( let mut skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; apply_skill_capabilities(agent_type, &mut skill); + apply_codex_native_skill_state(agent_type, &dirs, spec.kind, &mut skill); let content_path = skill_content_path(skill.layout, Path::new(&skill.path)); let content = fs::read_to_string(&content_path) .map_err(|e| AcpError::protocol(format!("failed to read skill content: {e}")))?; @@ -13954,6 +14389,7 @@ pub async fn acp_save_agent_skill( .map_err(|e| AcpError::protocol(format!("failed to write skill content: {e}")))?; skill.description = read_skill_description(&content_path); + apply_codex_native_skill_state(agent_type, &dirs, spec.kind, &mut skill); Ok(skill) } @@ -16640,7 +17076,7 @@ wire_api = "chat" } #[test] - fn skill_state_read_only_builtin_cannot_toggle() { + fn codex_system_skill_is_read_only_but_toggleable() { let tmp = tempfile::tempdir().expect("tempdir"); temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { let system_skill = tmp.path().join("skills/.system/task1a-system-demo"); @@ -16660,7 +17096,7 @@ wire_api = "chat" assert!(item.enabled); assert!(item.read_only); - assert!(!item.can_toggle); + assert!(item.can_toggle); }); } @@ -18247,174 +18683,565 @@ wire_api = "chat" #[test] fn skill_enabled_private_command_unisolatable_shared_roots_are_rejected() { let runtime = tokio::runtime::Runtime::new().unwrap(); - for (agent, relative) in [ - (AgentType::Codex, ".agents/skills"), - (AgentType::Gemini, ".gemini/skills"), - ] { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join(relative); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "shared").unwrap(); - let error = runtime - .block_on(acp_set_agent_skill_enabled( - agent, - AgentSkillScope::Project, - "demo".to_string(), - Some(tmp.path().to_string_lossy().into_owned()), - false, - )) - .unwrap_err(); - assert!(error.to_string().contains("shared skill root")); - assert!(root.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&root).exists()); - } - } - - #[test] - fn skill_enabled_private_concurrent_commands_return_requested_state() { let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join(".codex/skills"); + let root = tmp.path().join(".gemini/skills"); fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "body").unwrap(); - let workspace = tmp.path().to_string_lossy().into_owned(); - std::thread::scope(|threads| { - let handles = (0..8) - .map(|_| { - let workspace = &workspace; - threads.spawn(move || { - let runtime = tokio::runtime::Runtime::new().unwrap(); - for enabled in [false, false, true, true] { - let item = runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, - AgentSkillScope::Project, - "demo".to_string(), - Some(workspace.clone()), - enabled, - )) - .unwrap(); - assert_eq!(item.enabled, enabled); - } - }) - }) - .collect::>(); - for handle in handles { - handle.join().unwrap(); - } - }); - assert_eq!( - fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "body" - ); - assert!(!disabled_skill_root(&root).join("demo").exists()); - } - - #[test] - fn skill_enabled_private_duplicate_active_roots_are_rejected_before_move() { - let tmp = tempfile::tempdir().expect("tempdir"); - let roots = [ - tmp.path().join(".codex/skills"), - tmp.path().join(".agents/skills"), - ]; - for root in &roots { - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "original").unwrap(); - } - let runtime = tokio::runtime::Runtime::new().unwrap(); + fs::write(root.join("demo/SKILL.md"), "shared").unwrap(); let error = runtime .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, + AgentType::Gemini, AgentSkillScope::Project, - "demo".into(), + "demo".to_string(), Some(tmp.path().to_string_lossy().into_owned()), false, )) .unwrap_err(); - assert!(error.to_string().contains("multiple active")); - for root in &roots { + assert!(error.to_string().contains("shared skill root")); + assert!(root.join("demo/SKILL.md").is_file()); + assert!(!disabled_skill_root(&root).exists()); + } + + #[test] + fn skill_enabled_private_concurrent_commands_return_requested_state() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let root = tmp.path().join(".codex/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "body").unwrap(); + let workspace = tmp.path().to_string_lossy().into_owned(); + std::thread::scope(|threads| { + let handles = (0..8) + .map(|_| { + let workspace = &workspace; + threads.spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + for enabled in [false, false, true, true] { + let item = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".to_string(), + Some(workspace.clone()), + enabled, + )) + .unwrap(); + assert_eq!(item.enabled, enabled); + } + }) + }) + .collect::>(); + for handle in handles { + handle.join().unwrap(); + } + }); assert_eq!( fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "original" + "body" ); - assert!(!disabled_skill_root(root).exists()); - } + assert!(!disabled_skill_root(&root).join("demo").exists()); + }); } #[test] - fn skill_enabled_private_duplicate_active_layouts_are_rejected_before_move() { + fn codex_native_toggle_disables_duplicate_active_roots_without_moving_them() { let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join(".codex/skills"); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "directory").unwrap(); - fs::write(root.join("demo.md"), "flat").unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let error = runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, - AgentSkillScope::Project, - "demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - false, - )) - .unwrap_err(); - assert!(error.to_string().contains("multiple active")); - assert_eq!( - fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "directory" - ); - assert_eq!(fs::read_to_string(root.join("demo.md")).unwrap(), "flat"); - assert!(!disabled_skill_root(&root).exists()); + let codex_home = tmp.path().join("codex-home"); + let workspace = tmp.path().join("workspace"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + fs::create_dir_all(&codex_home).unwrap(); + fs::write( + codex_home.join("config.toml"), + "# keep this comment\nmodel = \"test-model\"\n\n[[skills.config]]\npath = \"/unrelated/SKILL.md\"\nenabled = false\n", + ) + .unwrap(); + let roots = [ + workspace.join(".codex/skills"), + workspace.join(".agents/skills"), + ]; + for root in &roots { + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "original").unwrap(); + } + let runtime = tokio::runtime::Runtime::new().unwrap(); + let workspace_path = Some(workspace.to_string_lossy().into_owned()); + let before = runtime + .block_on(acp_list_agent_skills( + AgentType::Codex, + workspace_path.clone(), + )) + .unwrap() + .skills + .into_iter() + .find(|skill| skill.scope == AgentSkillScope::Project && skill.id == "demo") + .unwrap(); + assert!(before.can_toggle, "Codex can disable every visible path"); + + let disabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + workspace_path.clone(), + false, + )) + .unwrap(); + assert!(!disabled.enabled); + assert_eq!( + disabled.path, before.path, + "the displayed path stays stable" + ); + + for root in &roots { + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "original" + ); + assert!(!disabled_skill_root(root).exists()); + } + + let config = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + assert!(config.contains("# keep this comment\nmodel = \"test-model\"")); + let parsed = config.parse::().unwrap(); + let configured = parsed["skills"]["config"].as_array().unwrap(); + assert_eq!(configured.len(), 3); + for root in &roots { + let expected_path = fs::canonicalize(root.join("demo/SKILL.md")).unwrap(); + let expected = expected_path.to_string_lossy(); + assert!(configured.iter().any(|entry| { + entry.get("path").and_then(toml::Value::as_str) == Some(expected.as_ref()) + && entry.get("enabled").and_then(toml::Value::as_bool) == Some(false) + })); + } + + let listed = runtime + .block_on(acp_list_agent_skills(AgentType::Codex, workspace_path)) + .unwrap() + .skills + .into_iter() + .find(|skill| skill.scope == AgentSkillScope::Project && skill.id == "demo") + .unwrap(); + assert!(!listed.enabled); + assert!(listed.can_toggle); + assert_eq!(listed.path, before.path); + + let enabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(workspace.to_string_lossy().into_owned()), + true, + )) + .unwrap(); + assert!(enabled.enabled); + assert_eq!(enabled.path, before.path); + let config = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let parsed = config.parse::().unwrap(); + for root in &roots { + let expected_path = fs::canonicalize(root.join("demo/SKILL.md")).unwrap(); + let expected = expected_path.to_string_lossy(); + assert!(parsed["skills"]["config"] + .as_array() + .unwrap() + .iter() + .any(|entry| { + entry.get("path").and_then(toml::Value::as_str) == Some(expected.as_ref()) + && entry.get("enabled").and_then(toml::Value::as_bool) == Some(true) + })); + } + }); } + #[cfg(unix)] #[test] - fn skill_enabled_private_save_new_then_edit_disabled_and_reenable() { + fn codex_native_toggle_keeps_shared_vault_links_in_place() { let tmp = tempfile::tempdir().expect("tempdir"); - let workspace = Some(tmp.path().to_string_lossy().into_owned()); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let saved = runtime - .block_on(acp_save_agent_skill( - AgentType::Codex, - AgentSkillScope::Project, - "demo".into(), - "original".into(), - workspace.clone(), - None, - )) - .unwrap(); - assert!(saved.enabled); - assert_eq!(saved.layout, AgentSkillLayout::MarkdownFile); - runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, - AgentSkillScope::Project, - "demo".into(), - workspace.clone(), - false, - )) + let codex_home = tmp.path().join("codex-home"); + let workspace = tmp.path().join("workspace"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let canonical = workspace + .join(".agents/.skills.codeg-disabled") + .join("agent-reach"); + fs::create_dir_all(&canonical).unwrap(); + fs::write(canonical.join("SKILL.md"), "shared skill").unwrap(); + + let links = [ + workspace.join(".codex/skills/agent-reach"), + workspace.join(".gemini/skills/agent-reach"), + workspace.join(".cursor/skills/agent-reach"), + ]; + for link in &links { + fs::create_dir_all(link.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&canonical, link).unwrap(); + } + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let workspace_path = Some(workspace.to_string_lossy().into_owned()); + let before = runtime + .block_on(acp_list_agent_skills( + AgentType::Codex, + workspace_path.clone(), + )) + .unwrap() + .skills + .into_iter() + .find(|skill| skill.scope == AgentSkillScope::Project && skill.id == "agent-reach") + .unwrap(); + assert!(before.enabled); + assert!(before.can_toggle); + assert_eq!(Path::new(&before.path), links[0]); + + let disabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "agent-reach".into(), + workspace_path.clone(), + false, + )) + .unwrap(); + assert!(!disabled.enabled); + assert_eq!(disabled.path, before.path); + assert!(canonical.join("SKILL.md").is_file()); + for link in &links { + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(link).unwrap(), canonical); + } + + let config = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let parsed = config.parse::().unwrap(); + let configured = parsed["skills"]["config"].as_array().unwrap(); + assert_eq!(configured.len(), 1); + let canonical_skill_md = fs::canonicalize(canonical.join("SKILL.md")).unwrap(); + assert_eq!( + configured[0].get("path").and_then(toml::Value::as_str), + canonical_skill_md.to_str() + ); + assert_eq!( + configured[0].get("enabled").and_then(toml::Value::as_bool), + Some(false) + ); + + let listed = runtime + .block_on(acp_list_agent_skills( + AgentType::Codex, + workspace_path.clone(), + )) + .unwrap() + .skills + .into_iter() + .find(|skill| skill.scope == AgentSkillScope::Project && skill.id == "agent-reach") + .unwrap(); + assert!(!listed.enabled); + assert!(listed.can_toggle); + assert_eq!(listed.path, before.path); + + let enabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "agent-reach".into(), + workspace_path, + true, + )) + .unwrap(); + assert!(enabled.enabled); + assert_eq!(enabled.path, before.path); + for link in &links { + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(link).unwrap(), canonical); + } + }); + } + + #[test] + fn codex_native_toggle_honors_name_config_entries() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let workspace = tmp.path().join("workspace"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + fs::create_dir_all(&codex_home).unwrap(); + fs::write( + codex_home.join("config.toml"), + "[[skills.config]]\nname = \"demo\"\nenabled = false\n", + ) .unwrap(); - let saved = runtime - .block_on(acp_save_agent_skill( - AgentType::Codex, - AgentSkillScope::Project, - "demo".into(), - "updated".into(), - workspace.clone(), - Some(AgentSkillLayout::SkillDirectory), - )) + let skill = workspace.join(".codex/skills/demo"); + fs::create_dir_all(&skill).unwrap(); + fs::write( + skill.join("SKILL.md"), + "---\nname: demo\ndescription: Demo\n---\n", + ) .unwrap(); - assert!(!saved.enabled); - assert_eq!(saved.layout, AgentSkillLayout::MarkdownFile); - let enabled = runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, - AgentSkillScope::Project, - "demo".into(), - workspace, - true, - )) + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let workspace_path = Some(workspace.to_string_lossy().into_owned()); + let listed = runtime + .block_on(acp_list_agent_skills( + AgentType::Codex, + workspace_path.clone(), + )) + .unwrap() + .skills + .into_iter() + .find(|skill| skill.scope == AgentSkillScope::Project && skill.id == "demo") + .unwrap(); + assert!(!listed.enabled); + assert!(listed.can_toggle); + + let enabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + workspace_path, + true, + )) + .unwrap(); + assert!(enabled.enabled); + + let config = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let parsed = config.parse::().unwrap(); + let configured = parsed["skills"]["config"].as_array().unwrap(); + assert_eq!(configured.len(), 2); + assert_eq!( + configured[0].get("name").and_then(toml::Value::as_str), + Some("demo") + ); + assert_eq!( + configured[0].get("enabled").and_then(toml::Value::as_bool), + Some(false), + "a path-scoped toggle must not rewrite the broader name selector" + ); + let expected_path = fs::canonicalize(skill.join("SKILL.md")).unwrap(); + assert!(configured.iter().any(|entry| { + entry.get("path").and_then(toml::Value::as_str) + == expected_path.to_str() + && entry.get("enabled").and_then(toml::Value::as_bool) == Some(true) + })); + }); + } + + #[test] + fn codex_skill_config_path_selector_overrides_later_name_selector() { + let config = parse_codex_skill_config( + "[[skills.config]]\npath = \"/tmp/demo/SKILL.md\"\nenabled = true\n\ + \n[[skills.config]]\nname = \"demo\"\nenabled = false\n", + Path::new("/tmp/codex-home"), + ) + .unwrap(); + + assert!(config.skill_enabled(Path::new("/tmp/demo/SKILL.md"), "demo")); + } + + #[test] + fn codex_skill_config_selectorless_entry_matches_no_skill() { + let config = parse_codex_skill_config( + "[[skills.config]]\nenabled = false\n", + Path::new("/tmp/codex-home"), + ) + .expect("Codex accepts selectorless entries"); + + assert!(config.skill_enabled(Path::new("/tmp/demo/SKILL.md"), "demo")); + } + + #[test] + fn codex_skill_config_requires_enabled_field() { + let error = parse_codex_skill_config( + "[[skills.config]]\nname = \"demo\"\n", + Path::new("/tmp/codex-home"), + ) + .expect_err("Codex rejects entries without enabled"); + + assert!(error.to_string().contains("enabled")); + } + + #[test] + fn codex_native_toggle_updates_inline_array_config() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let workspace = tmp.path().join("workspace"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let skill = workspace.join(".codex/skills/demo"); + fs::create_dir_all(&skill).unwrap(); + fs::write(skill.join("SKILL.md"), "inline config").unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let content_path = fs::canonicalize(skill.join("SKILL.md")).unwrap(); + fs::write( + codex_home.join("config.toml"), + format!( + "# inline form is valid Codex TOML\n[skills]\nconfig = [{{ path = \"{}\", enabled = true }}]\n", + content_path.display() + ), + ) .unwrap(); - assert!(enabled.enabled); - assert_eq!(fs::read_to_string(enabled.path).unwrap(), "updated"); + + let disabled = tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(workspace.to_string_lossy().into_owned()), + false, + )) + .expect("inline-array config should remain toggleable"); + assert!(!disabled.enabled); + + let written = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + assert!(written.contains("# inline form is valid Codex TOML")); + assert!(written.contains("config = [")); + let parsed = written.parse::().unwrap(); + let entries = parsed["skills"]["config"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].get("path").and_then(toml::Value::as_str), + content_path.to_str() + ); + assert_eq!( + entries[0].get("enabled").and_then(toml::Value::as_bool), + Some(false) + ); + assert!(skill.join("SKILL.md").is_file()); + }); + } + + #[test] + fn codex_config_concurrent_rmw_writes_preserve_both_changes() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + fs::create_dir_all(&codex_home).unwrap(); + let config_path = codex_home.join("config.toml"); + let padding = "# keep this unrelated config padding\n".repeat(50_000); + + for attempt in 0..4 { + fs::write( + &config_path, + format!( + "model = \"old\"\nmodel_catalog_json = \"{}\"\n{padding}", + crate::acp::codex_model_catalog::CATALOG_REL, + ), + ) + .unwrap(); + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + std::thread::scope(|threads| { + let first = barrier.clone(); + let set_model = threads.spawn(move || { + first.wait(); + apply_codex_root_model_action(&CodexModelAction::Set("new".into())) + }); + let second = barrier.clone(); + let drop_catalog = threads.spawn(move || { + second.wait(); + drop_codex_catalog_reference() + }); + barrier.wait(); + set_model.join().unwrap().unwrap(); + drop_catalog.join().unwrap().unwrap(); + }); + + let written = fs::read_to_string(&config_path).unwrap(); + let parsed = written.parse::().unwrap(); + assert_eq!( + parsed.get("model").and_then(toml::Value::as_str), + Some("new"), + "model update was lost on attempt {attempt}", + ); + assert!( + parsed.get("model_catalog_json").is_none(), + "catalog cleanup was lost on attempt {attempt}", + ); + } + }); + } + + #[test] + fn codex_native_toggle_disables_duplicate_active_layouts_without_moving_them() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let root = tmp.path().join("workspace/.codex/skills"); + fs::create_dir_all(root.join("demo")).unwrap(); + fs::write(root.join("demo/SKILL.md"), "directory").unwrap(); + fs::write(root.join("demo.md"), "flat").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let disabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(tmp.path().join("workspace").to_string_lossy().into_owned()), + false, + )) + .unwrap(); + assert!(!disabled.enabled); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "directory" + ); + assert_eq!(fs::read_to_string(root.join("demo.md")).unwrap(), "flat"); + assert!(!disabled_skill_root(&root).exists()); + let config = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let parsed = config.parse::().unwrap(); + assert_eq!(parsed["skills"]["config"].as_array().unwrap().len(), 2); + }); + } + + #[test] + fn skill_enabled_private_save_new_then_edit_disabled_and_reenable() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let workspace = Some(tmp.path().to_string_lossy().into_owned()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let saved = runtime + .block_on(acp_save_agent_skill( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + "original".into(), + workspace.clone(), + None, + )) + .unwrap(); + assert!(saved.enabled); + assert_eq!(saved.layout, AgentSkillLayout::MarkdownFile); + runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + workspace.clone(), + false, + )) + .unwrap(); + let saved = runtime + .block_on(acp_save_agent_skill( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + "updated".into(), + workspace.clone(), + Some(AgentSkillLayout::SkillDirectory), + )) + .unwrap(); + assert!(!saved.enabled); + assert_eq!(saved.layout, AgentSkillLayout::MarkdownFile); + let enabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + workspace, + true, + )) + .unwrap(); + assert!(enabled.enabled); + assert_eq!(fs::read_to_string(enabled.path).unwrap(), "updated"); + }); } #[test] @@ -18513,7 +19340,7 @@ wire_api = "chat" fn skill_enabled_private_safety_vault_alias_to_native_root_is_rejected() { for destination in [".agents/skills", "skills"] { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".codex/skills"); + let root = tmp.path().join(".gemini/skills"); let other = tmp.path().join(destination); fs::create_dir_all(root.join("demo")).unwrap(); fs::write(root.join("demo/SKILL.md"), "original").unwrap(); @@ -18523,7 +19350,7 @@ wire_api = "chat" tokio::runtime::Runtime::new() .unwrap() .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, + AgentType::Gemini, AgentSkillScope::Project, "demo".into(), Some(tmp.path().to_string_lossy().into_owned()), @@ -18638,7 +19465,7 @@ wire_api = "chat" }; let _registry_guard = hydrate_test_guard(); let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".codex/skills"); + let root = tmp.path().join(".gemini/skills"); fs::create_dir_all(root.join("demo")).unwrap(); fs::write(root.join("demo/SKILL.md"), "shared").unwrap(); let definition = CustomAgentDef { @@ -18665,7 +19492,7 @@ wire_api = "chat" let result = tokio::runtime::Runtime::new() .unwrap() .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, + AgentType::Gemini, AgentSkillScope::Project, "demo".into(), Some(tmp.path().to_string_lossy().into_owned()), @@ -18843,13 +19670,12 @@ wire_api = "chat" } #[test] - fn skill_enabled_private_read_only_skill_cannot_be_toggled() { + fn codex_native_toggle_disables_read_only_system_skill_without_editing_it() { let tmp = tempfile::tempdir().expect("tempdir"); temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { let system_skill = tmp.path().join("skills/.system/task1-system-demo"); std::fs::create_dir_all(&system_skill).expect("create system skill"); - std::fs::write(system_skill.join("SKILL.md"), "system\n") - .expect("write system skill"); + std::fs::write(system_skill.join("SKILL.md"), "system\n").expect("write system skill"); let runtime = tokio::runtime::Runtime::new().expect("runtime"); let listed = runtime @@ -18861,9 +19687,9 @@ wire_api = "chat" .find(|item| item.id == "task1-system-demo") .expect("listed system skill"); assert!(item.read_only); - assert!(!item.can_toggle); + assert!(item.can_toggle); - let error = runtime + let disabled = runtime .block_on(acp_set_agent_skill_enabled( AgentType::Codex, AgentSkillScope::Global, @@ -18871,10 +19697,77 @@ wire_api = "chat" None, false, )) - .expect_err("system skill toggle must fail"); - assert!(error.to_string().contains("cannot be toggled")); + .expect("system skill availability should be configurable"); + assert!(!disabled.enabled); + assert!(disabled.read_only); + assert!(disabled.can_toggle); + assert_eq!(disabled.path, item.path); assert!(system_skill.join("SKILL.md").is_file()); + + let listed = runtime + .block_on(acp_list_agent_skills(AgentType::Codex, None)) + .expect("list disabled system skill"); + let item = listed + .skills + .iter() + .find(|item| item.id == "task1-system-demo") + .expect("disabled system skill remains listed"); + assert!(!item.enabled); + assert!(item.read_only); + assert!(item.can_toggle); + }); + } + + #[cfg(unix)] + #[test] + fn codex_native_toggle_preserves_config_symlink() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let real_dir = tmp.path().join("real-config"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&real_dir).unwrap(); + let target = real_dir.join("config.toml"); + fs::write(&target, "# linked config\nmodel = \"test-model\"\n").unwrap(); + let relative_target = Path::new("../real-config/config.toml"); + std::os::unix::fs::symlink(relative_target, codex_home.join("config.toml")).unwrap(); + + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let system_skill = codex_home.join("skills/.system/task1-linked-config-demo"); + fs::create_dir_all(&system_skill).unwrap(); + fs::write(system_skill.join("SKILL.md"), "system\n").unwrap(); + + let disabled = tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Global, + "task1-linked-config-demo".into(), + None, + false, + )) + .expect("toggle through linked config"); + assert!(!disabled.enabled); }); + + let link = codex_home.join("config.toml"); + assert!( + fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), + "atomic replacement must update the target without replacing the link", + ); + assert_eq!(fs::read_link(&link).unwrap(), relative_target); + let written = fs::read_to_string(&target).unwrap(); + assert!(written.contains("# linked config")); + let parsed = written.parse::().unwrap(); + assert_eq!( + parsed.get("model").and_then(toml::Value::as_str), + Some("test-model"), + ); + assert_eq!( + parsed["skills"]["config"].as_array().unwrap()[0] + .get("enabled") + .and_then(toml::Value::as_bool), + Some(false), + ); } #[test] diff --git a/src/components/settings/skills-settings.test.tsx b/src/components/settings/skills-settings.test.tsx index 9e4b22113e..6340f23a2a 100644 --- a/src/components/settings/skills-settings.test.tsx +++ b/src/components/settings/skills-settings.test.tsx @@ -178,6 +178,20 @@ describe("SkillsSettings availability", () => { ) }) + it("keeps a read-only system skill toggleable when availability is configurable", async () => { + api.acpListAgentSkills.mockResolvedValue( + listResult(skill({ read_only: true, can_toggle: true })) + ) + + renderSettings() + + const availability = await screen.findByRole("switch", { + name: "Toggle Demo Skill for Codex", + }) + expect(availability).toBeEnabled() + expect(availability).toHaveAttribute("title", "Enabled") + }) + it("explains when a shared skill cannot be toggled independently", async () => { api.acpListAgentSkills.mockResolvedValue( listResult(skill({ read_only: false, can_toggle: false })) diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index a5a7b93029..d39c006103 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -981,13 +981,13 @@ export function SkillsSettings() { filteredSkills.map((skill) => { const isActive = skill.id === selectedSkillId const deleting = skillDeletingId === skill.id - const availabilityHint = skill.read_only - ? skillsT("availability.readOnly") - : !skill.can_toggle - ? skillsT("availability.cannotIsolate") - : skill.enabled - ? skillsT("availability.enabled") - : skillsT("availability.disabled") + const availabilityHint = !skill.can_toggle + ? skill.read_only + ? skillsT("availability.readOnly") + : skillsT("availability.cannotIsolate") + : skill.enabled + ? skillsT("availability.enabled") + : skillsT("availability.disabled") return ( From a76d5bb1f8f21837750a32113c85ba592009911e Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:25:16 +0800 Subject: [PATCH 18/23] fix(skills): clarify codex session scope --- .../settings/skills-settings.test.tsx | 41 +++++++++++++++++++ src/components/settings/skills-settings.tsx | 9 +++- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + 12 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/components/settings/skills-settings.test.tsx b/src/components/settings/skills-settings.test.tsx index 6340f23a2a..6b6cdafcff 100644 --- a/src/components/settings/skills-settings.test.tsx +++ b/src/components/settings/skills-settings.test.tsx @@ -46,6 +46,8 @@ const messages = { ...enMessages.SkillsSettings.toasts, enabled: "Skill enabled", disabled: "Skill disabled", + codexNewSession: + "Applies to new Codex sessions. Existing sessions may retain skills they already loaded.", toggleFailed: "Failed to update skill availability", }, }, @@ -158,6 +160,45 @@ describe("SkillsSettings availability", () => { await waitFor(() => expect(api.acpListAgentSkills).toHaveBeenCalledTimes(3)) await waitFor(() => expect(availability).not.toBeChecked()) expect(api.acpReadAgentSkill).toHaveBeenCalledTimes(1) + expect(toast.success).toHaveBeenCalledWith("Skill disabled", { + description: + "Applies to new Codex sessions. Existing sessions may retain skills they already loaded.", + }) + }) + + it("keeps the existing success toast for non-Codex agents", async () => { + const claudeSkill = skill({ + path: "/home/test/.claude/skills/demo/SKILL.md", + }) + api.acpListAgents.mockResolvedValue([claudeAgent]) + api.acpListAgentSkills + .mockResolvedValueOnce(listResult(claudeSkill)) + .mockResolvedValueOnce(listResult(claudeSkill)) + .mockResolvedValueOnce( + listResult(skill({ ...claudeSkill, enabled: false })) + ) + api.acpSetAgentSkillEnabled.mockResolvedValue( + skill({ ...claudeSkill, enabled: false }) + ) + + renderSettings() + + fireEvent.click( + await screen.findByRole("switch", { + name: "Toggle Demo Skill for Claude Code", + }) + ) + + await waitFor(() => + expect(api.acpSetAgentSkillEnabled).toHaveBeenCalledWith({ + agentType: "claude_code", + scope: "global", + skillId: "demo", + workspacePath: null, + enabled: false, + }) + ) + await waitFor(() => expect(api.acpListAgentSkills).toHaveBeenCalledTimes(3)) expect(toast.success).toHaveBeenCalledWith("Skill disabled") }) diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index d39c006103..9cd6ee9ec8 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -448,7 +448,14 @@ export function SkillsSettings() { skill.scope === "project" ? workspacePathForRequest : null, enabled, }) - toast.success(skillsT(enabled ? "toasts.enabled" : "toasts.disabled")) + const message = skillsT(enabled ? "toasts.enabled" : "toasts.disabled") + if (selectedAgent.agent_type === "codex") { + toast.success(message, { + description: skillsT("toasts.codexNewSession"), + }) + } else { + toast.success(message) + } } catch (err) { toast.error(skillsT("toasts.toggleFailed"), { description: toErrorMessage(err), diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 402ca1bf40..6d2e3b1473 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -589,6 +589,7 @@ "deleteFailed": "فشل حذف Skill", "enabled": "تم تفعيل Skill", "disabled": "تم تعطيل Skill", + "codexNewSession": "ينطبق على جلسات Codex الجديدة. قد تحتفظ الجلسات الحالية بالـ Skills التي سبق تحميلها.", "toggleFailed": "فشل تحديث حالة توفر Skill" }, "templates": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d35f5ffbbf..95121d45f3 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -589,6 +589,7 @@ "deleteFailed": "Skill konnte nicht gelöscht werden", "enabled": "Skill aktiviert", "disabled": "Skill deaktiviert", + "codexNewSession": "Gilt für neue Codex-Sitzungen. Bereits geöffnete Sitzungen können zuvor geladene Skills behalten.", "toggleFailed": "Skill-Verfügbarkeit konnte nicht aktualisiert werden" }, "templates": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 4e3e6e61f6..4fa02df18f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -589,6 +589,7 @@ "deleteFailed": "Failed to delete skill", "enabled": "Skill enabled", "disabled": "Skill disabled", + "codexNewSession": "Applies to new Codex sessions. Existing sessions may retain skills they already loaded.", "toggleFailed": "Failed to update skill availability" }, "templates": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index c3cfe55693..a96081bed9 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -589,6 +589,7 @@ "deleteFailed": "No se pudo eliminar la Skill", "enabled": "Skill activada", "disabled": "Skill desactivada", + "codexNewSession": "Se aplica a las sesiones nuevas de Codex. Las sesiones existentes pueden conservar las Skills que ya cargaron.", "toggleFailed": "No se pudo actualizar la disponibilidad de la Skill" }, "templates": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 3878608452..25b16ce43c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -589,6 +589,7 @@ "deleteFailed": "Échec de la suppression de la Skill", "enabled": "Skill activée", "disabled": "Skill désactivée", + "codexNewSession": "S'applique aux nouvelles sessions Codex. Les sessions existantes peuvent conserver les Skills déjà chargées.", "toggleFailed": "Échec de la mise à jour de la disponibilité de la Skill" }, "templates": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index eaf33524d3..9010357bdc 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -589,6 +589,7 @@ "deleteFailed": "Skillの削除に失敗しました", "enabled": "Skillを有効にしました", "disabled": "Skillを無効にしました", + "codexNewSession": "新しく作成した Codex セッションに適用されます。既存のセッションには、すでに読み込まれた Skill が残る場合があります。", "toggleFailed": "Skillの利用可否の更新に失敗しました" }, "templates": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ef97d7fe86..a5ffec90d9 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -589,6 +589,7 @@ "deleteFailed": "Skill 삭제에 실패했습니다", "enabled": "Skill이 활성화되었습니다", "disabled": "Skill이 비활성화되었습니다", + "codexNewSession": "새로 만든 Codex 세션에 적용됩니다. 기존 세션에는 이미 불러온 Skill이 남아 있을 수 있습니다.", "toggleFailed": "Skill 사용 여부를 업데이트하지 못했습니다" }, "templates": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6506acdbfe..a178043243 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -589,6 +589,7 @@ "deleteFailed": "Falha ao excluir Skill", "enabled": "Skill ativada", "disabled": "Skill desativada", + "codexNewSession": "Aplica-se a novas sessões do Codex. Sessões existentes podem manter as Skills já carregadas.", "toggleFailed": "Falha ao atualizar a disponibilidade da Skill" }, "templates": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 94a620bd5e..7a26f6a869 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -589,6 +589,7 @@ "deleteFailed": "删除 Skill 失败", "enabled": "Skill 已启用", "disabled": "Skill 已禁用", + "codexNewSession": "仅对新建的 Codex 会话生效;已打开的会话可能仍保留此前加载的 Skill。", "toggleFailed": "更新 Skill 可用状态失败" }, "templates": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a5ac7cb871..324f63ff8e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -589,6 +589,7 @@ "deleteFailed": "刪除 Skill 失敗", "enabled": "Skill 已啟用", "disabled": "Skill 已停用", + "codexNewSession": "僅對新建的 Codex 工作階段生效;已開啟的工作階段可能仍保留先前載入的 Skill。", "toggleFailed": "更新 Skill 可用狀態失敗" }, "templates": { From 67afdf06cf7752267c2d3cf7a3074e424e29586a Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:57:54 +0800 Subject: [PATCH 19/23] =?UTF-8?q?fix(skills):=20=E4=BF=AE=E6=AD=A3=20Codex?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E8=A7=84=E5=88=99=E4=BC=98=E5=85=88?= =?UTF-8?q?=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-09-07-agent-skill-switches.md | 8 +- .../2026-09-07-agent-skill-switches-design.md | 52 +++-- src-tauri/src/commands/acp.rs | 201 ++++++++++++++---- src-tauri/src/commands/folders.rs | 5 +- 4 files changed, 209 insertions(+), 57 deletions(-) diff --git a/docs/superpowers/plans/2026-09-07-agent-skill-switches.md b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md index 37238abbdb..0941c23aca 100644 --- a/docs/superpowers/plans/2026-09-07-agent-skill-switches.md +++ b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md @@ -4,15 +4,15 @@ **Goal:** Add real per-agent availability switches to Settings > Skills without letting a shared skill toggle silently affect another Codeg-managed agent. -**Architecture:** Native skill roots remain authoritative. Disabled entries live in deterministic sibling vaults; shared entries are fanned out into agent-unique roots before the shared source is hidden. The existing list/read/save/delete surface is extended so disabled skills remain manageable, and one new command performs serialized toggles over both transports. +**Architecture:** Codex uses its official ordered `skills.config` rules, so toggles never move Codex files and can cover read-only system skills. Other agents keep native skill roots authoritative: disabled entries live in deterministic sibling vaults, and shared entries are fanned out into agent-unique roots before the shared source is hidden. The existing list/read/save/delete surface is extended so disabled skills remain manageable, and one new command performs serialized toggles over both transports. **Tech Stack:** Rust 2021, Tauri 2, Axum, Next.js 16, React 19, TypeScript, next-intl, Vitest. ## Global Constraints - The switch is per agent and per scope; turning a shared skill off for one agent must preserve availability for every other Codeg-managed agent that already sees it. -- A disabled skill must not remain in any native scan root used by the selected agent. -- Read-only CLI skills cannot be toggled. +- A disabled skill must be excluded from the selected agent's effective native discovery result. Codex does this through `skills.config`; other agents use filesystem isolation. +- Read-only CLI skills cannot be moved or edited. Codex system skills can still be toggled because its official configuration controls availability without mutating skill content. - Existing user files and unrelated worktree changes must be preserved. - Both Tauri desktop and Axum server transports must expose the same behavior. @@ -35,7 +35,7 @@ then exercise the wished-for helpers: ```rust let disabled = disabled_skill_root(&skills); -set_skill_enabled_in_roots(&peers, AgentType::Codex, AgentSkillScope::Global, "demo", false)?; +set_skill_enabled_in_roots(&peers, AgentType::Gemini, AgentSkillScope::Global, "demo", false)?; assert!(!skills.join("demo").exists()); assert!(disabled.join("demo").join("SKILL.md").is_file()); let listed = list_skills_from_roots(AgentSkillScope::Global, &[skills], kind)?; diff --git a/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md index fe45c9414a..6877d3e514 100644 --- a/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md +++ b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md @@ -27,17 +27,39 @@ disabled state. - A switch shows an in-progress state while the filesystem operation runs. - On failure, the authoritative list is reloaded, the switch returns to its prior state, and a localized error toast is shown. -- CLI-owned read-only skills remain visible but cannot be toggled. +- For non-Codex agents, CLI-owned read-only skills remain visible but cannot be + toggled. Codex system skills remain toggleable because Codeg changes only + Codex's official availability configuration, not the skill files. - A skill whose shared installation cannot be separated without changing another configured agent is marked non-toggleable instead of pretending the operation succeeded. - A new or reconnected agent session is required when an already-running agent caches its skill inventory. -## Storage Model +## Agent-Specific Control Models -No database flag is authoritative. The filesystem remains the source of truth -because the agent CLIs scan it directly. +No database flag is authoritative. Codeg changes the native state consumed by +each agent so its own discovery result remains the source of truth. + +### Codex Native Configuration + +Codeg does not move Codex skill files. It reads and atomically updates +`CODEX_HOME/config.toml` using Codex's official `skills.config` entries. Rules +are evaluated in file order and the last matching `path` or `name` selector +wins. When a broader or later selector would override the requested state, +Codeg appends a path-specific rule so the requested state is effective without +rewriting the user's broader rule. A repeated toggle updates that trailing +path rule instead of growing the configuration indefinitely. + +This applies to project, user, plugin, and system skills, including skill files +that are read-only. Their files and displayed locations remain unchanged. A +new Codex session is required because an existing session may have cached its +skill inventory. + +### Filesystem Isolation For Other Agents + +Agents without a native availability configuration continue to use filesystem +isolation because their CLIs scan native skill roots directly. For each native skill root, Codeg uses a sibling vault that is outside the agent's scan path: @@ -92,9 +114,11 @@ enabled: bool can_toggle: bool ``` -The list command scans active roots first and disabled vaults second. Active -entries win when the same ID occurs more than once. This means a peer link is -reported enabled even though its canonical source is held in a shared vault. +For non-Codex agents, the list command scans active roots first and disabled +vaults second. Active entries win when the same ID occurs more than once. This +means a peer link is reported enabled even though its canonical source is held +in a shared vault. For Codex, the list command overlays the effective native +configuration state on every discovered skill and keeps its original path. A new command is available over both Tauri and Axum transports: @@ -122,14 +146,18 @@ and the generic `useAgentSkills` hook returns enabled entries only. - Repeated enable or disable requests are idempotent. - Symbolic links are moved as links, never followed and copied during private disable operations. -- Built-in system paths retain the existing backend write protection. +- Built-in system paths retain the existing backend content-write protection. + Codex availability remains configurable because toggling writes only the + Codex config file. ## Tests -Rust tests cover private directory and flat-file toggles, disabled discovery, -idempotency, shared fan-out, peer isolation, collision refusal, rollback, and -read-only rejection. Existing skill storage tests continue to pin each agent's -native roots. +Rust tests cover Codex rule precedence, both supported TOML array forms, +read-only/system-skill availability, stable paths, atomic config writes, and +new-session behavior. They also cover private directory and flat-file toggles, +disabled discovery, idempotency, shared fan-out, peer isolation, collision +refusal, rollback, and non-Codex read-only rejection. Existing skill storage +tests continue to pin each agent's native roots. Frontend tests cover switch state, the exact toggle request, cache invalidation, authoritative reload, disabled autocomplete filtering, read-only/non-toggleable diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index edca6139f0..9232d6da4c 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8846,27 +8846,18 @@ fn parse_codex_skill_config( impl CodexSkillConfig { fn skill_enabled(&self, path: &Path, name: &str) -> bool { - if let Some(enabled) = self - .entries - .iter() - .filter(|entry| { - entry - .path - .as_deref() - .is_some_and(|configured| same_skill_config_path(configured, path)) - }) - .map(|entry| entry.enabled) - .next_back() - { - return enabled; - } - - self.entries - .iter() - .filter(|entry| entry.name.as_deref() == Some(name)) - .map(|entry| entry.enabled) - .next_back() - .unwrap_or(true) + self.entries.iter().fold(true, |enabled, entry| { + let path_matches = entry + .path + .as_deref() + .is_some_and(|configured| same_skill_config_path(configured, path)); + let name_matches = entry.name.as_deref() == Some(name); + if path_matches || name_matches { + entry.enabled + } else { + enabled + } + }) } } @@ -8885,6 +8876,7 @@ fn codex_skill_entries_enabled( fn apply_codex_skill_enabled_config( base_toml: &str, codex_home: &Path, + skill_name: &str, paths: &[PathBuf], enabled: bool, ) -> Result { @@ -8917,22 +8909,30 @@ fn apply_codex_skill_enabled_config( let config = skills .get_mut("config") .ok_or_else(|| AcpError::protocol("invalid codex config.toml: missing skills.config"))?; + // Codex applies matching rules in order. Update an existing path rule only + // when it is the final match; otherwise append a path-specific override. match config { toml_edit::Item::ArrayOfTables(config) => { for path in unique_paths { - let mut matched = false; - for entry in config.iter_mut() { + let mut last_matching_path = None; + for (index, entry) in config.iter().enumerate() { let path_matches = entry .get("path") .and_then(toml_edit::Item::as_str) .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) .is_some_and(|configured| same_skill_config_path(&configured, &path)); - if path_matches { - entry.insert("enabled", toml_edit::value(enabled)); - matched = true; + let name_matches = + entry.get("name").and_then(toml_edit::Item::as_str) == Some(skill_name); + if path_matches || name_matches { + last_matching_path = path_matches.then_some(index); } } - if !matched { + if let Some(index) = last_matching_path { + config + .get_mut(index) + .expect("matching Codex skill config entry must exist") + .insert("enabled", toml_edit::value(enabled)); + } else { let mut entry = toml_edit::Table::new(); entry.insert("path", toml_edit::value(path.to_string_lossy().as_ref())); entry.insert("enabled", toml_edit::value(enabled)); @@ -8942,9 +8942,9 @@ fn apply_codex_skill_enabled_config( } toml_edit::Item::Value(toml_edit::Value::Array(config)) => { for path in unique_paths { - let mut matched = false; - for value in config.iter_mut() { - let entry = value.as_inline_table_mut().ok_or_else(|| { + let mut last_matching_path = None; + for (index, value) in config.iter().enumerate() { + let entry = value.as_inline_table().ok_or_else(|| { AcpError::protocol( "invalid codex config.toml: skills.config entry must be a table", ) @@ -8954,12 +8954,19 @@ fn apply_codex_skill_enabled_config( .and_then(toml_edit::Value::as_str) .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) .is_some_and(|configured| same_skill_config_path(&configured, &path)); - if path_matches { - entry.insert("enabled", toml_edit::Value::from(enabled)); - matched = true; + let name_matches = + entry.get("name").and_then(toml_edit::Value::as_str) == Some(skill_name); + if path_matches || name_matches { + last_matching_path = path_matches.then_some(index); } } - if !matched { + if let Some(index) = last_matching_path { + config + .get_mut(index) + .and_then(toml_edit::Value::as_inline_table_mut) + .expect("matching Codex skill config entry must be an inline table") + .insert("enabled", toml_edit::Value::from(enabled)); + } else { let mut entry = toml_edit::InlineTable::new(); entry.insert( "path", @@ -8997,7 +9004,7 @@ fn set_codex_skill_enabled_native( .iter() .map(absolute_skill_content_path) .collect::, _>>()?; - let next = apply_codex_skill_enabled_config(&base, &codex_home, &paths, enabled)?; + let next = apply_codex_skill_enabled_config(&base, &codex_home, &listed.id, &paths, enabled)?; let updated = parse_codex_skill_config(&next, &codex_home)?; if codex_skill_entries_enabled(active, &updated)? != enabled { return Err(AcpError::protocol( @@ -19024,15 +19031,133 @@ wire_api = "chat" } #[test] - fn codex_skill_config_path_selector_overrides_later_name_selector() { - let config = parse_codex_skill_config( + fn codex_skill_config_last_matching_selector_wins() { + let path_then_name = parse_codex_skill_config( "[[skills.config]]\npath = \"/tmp/demo/SKILL.md\"\nenabled = true\n\ \n[[skills.config]]\nname = \"demo\"\nenabled = false\n", Path::new("/tmp/codex-home"), ) .unwrap(); + let name_then_path = parse_codex_skill_config( + "[[skills.config]]\nname = \"demo\"\nenabled = false\n\ + \n[[skills.config]]\npath = \"/tmp/demo/SKILL.md\"\nenabled = true\n", + Path::new("/tmp/codex-home"), + ) + .unwrap(); - assert!(config.skill_enabled(Path::new("/tmp/demo/SKILL.md"), "demo")); + assert!(!path_then_name.skill_enabled(Path::new("/tmp/demo/SKILL.md"), "demo")); + assert!(name_then_path.skill_enabled(Path::new("/tmp/demo/SKILL.md"), "demo")); + } + + #[test] + fn codex_skill_config_appends_path_override_after_later_name_selector() { + let path = PathBuf::from("/tmp/demo/SKILL.md"); + let updated = apply_codex_skill_enabled_config( + "[[skills.config]]\npath = \"/tmp/demo/SKILL.md\"\nenabled = false\n\ + \n[[skills.config]]\nname = \"demo\"\nenabled = false\n", + Path::new("/tmp/codex-home"), + "demo", + std::slice::from_ref(&path), + true, + ) + .unwrap(); + let parsed = updated.parse::().unwrap(); + let entries = parsed["skills"]["config"].as_array().unwrap(); + + assert_eq!(entries.len(), 3); + assert_eq!( + entries + .last() + .unwrap() + .get("path") + .and_then(toml::Value::as_str), + path.to_str() + ); + assert_eq!( + entries + .last() + .unwrap() + .get("enabled") + .and_then(toml::Value::as_bool), + Some(true) + ); + assert!( + parse_codex_skill_config(&updated, Path::new("/tmp/codex-home")) + .unwrap() + .skill_enabled(&path, "demo") + ); + + let updated_again = apply_codex_skill_enabled_config( + &updated, + Path::new("/tmp/codex-home"), + "demo", + std::slice::from_ref(&path), + false, + ) + .unwrap(); + let parsed = updated_again.parse::().unwrap(); + let entries = parsed["skills"]["config"].as_array().unwrap(); + assert_eq!(entries.len(), 3, "repeated toggles reuse the path override"); + assert!( + !parse_codex_skill_config(&updated_again, Path::new("/tmp/codex-home")) + .unwrap() + .skill_enabled(&path, "demo") + ); + } + + #[test] + fn codex_skill_config_appends_inline_path_override_after_later_name_selector() { + let path = PathBuf::from("/tmp/demo/SKILL.md"); + let updated = apply_codex_skill_enabled_config( + "[skills]\nconfig = [{ path = \"/tmp/demo/SKILL.md\", enabled = false }, { name = \"demo\", enabled = false }]\n", + Path::new("/tmp/codex-home"), + "demo", + std::slice::from_ref(&path), + true, + ) + .unwrap(); + let parsed = updated.parse::().unwrap(); + let entries = parsed["skills"]["config"].as_array().unwrap(); + + assert_eq!(entries.len(), 3); + assert_eq!( + entries + .last() + .unwrap() + .get("path") + .and_then(toml::Value::as_str), + path.to_str() + ); + assert_eq!( + entries + .last() + .unwrap() + .get("enabled") + .and_then(toml::Value::as_bool), + Some(true) + ); + assert!( + parse_codex_skill_config(&updated, Path::new("/tmp/codex-home")) + .unwrap() + .skill_enabled(&path, "demo") + ); + + let updated_again = apply_codex_skill_enabled_config( + &updated, + Path::new("/tmp/codex-home"), + "demo", + std::slice::from_ref(&path), + false, + ) + .unwrap(); + let parsed = updated_again.parse::().unwrap(); + let entries = parsed["skills"]["config"].as_array().unwrap(); + assert_eq!(entries.len(), 3, "repeated toggles reuse the path override"); + assert!( + !parse_codex_skill_config(&updated_again, Path::new("/tmp/codex-home")) + .unwrap() + .skill_enabled(&path, "demo") + ); } #[test] diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index f06c992b14..6b677c8f63 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -7783,10 +7783,9 @@ mod tests { let refused = git_delete_branch(repo.clone(), "wt".into(), false) .await .expect_err("git refuses a branch held by a worktree"); - let refused = format!("{refused:?}"); assert!( - refused.contains("used by worktree") || refused.contains("checked out at"), - "expected git's worktree refusal, got: {refused}" + format!("{refused:?}").contains("used by worktree"), + "expected git's worktree refusal, got: {refused:?}" ); // Resolve ours the way git resolves its own — while the directory is From 7b03371c8af3294b61ff702867fa6532162a0255 Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:09:08 +0800 Subject: [PATCH 20/23] =?UTF-8?q?docs(skills):=20=E6=BE=84=E6=B8=85?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=20Agent=20=E7=9A=84=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-09-07-agent-skill-switches-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md index 6877d3e514..f4b79bd0bf 100644 --- a/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md +++ b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md @@ -22,8 +22,8 @@ disabled state. - Every skill row has an availability switch for the currently selected agent. - An enabled skill remains discoverable by that agent. A disabled skill remains visible in Settings so it can be previewed, edited, deleted, or re-enabled, - but it is omitted from Codeg skill autocomplete and from the agent's native - scan roots. + but it is omitted from Codeg skill autocomplete and from the agent's + effective native discovery result. - A switch shows an in-progress state while the filesystem operation runs. - On failure, the authoritative list is reloaded, the switch returns to its prior state, and a localized error toast is shown. @@ -91,11 +91,11 @@ Example: ```text before: - ~/.agents/skills/pdf # Codex and Gemini can both see it + ~/.agents/skills/pdf # Cursor and Gemini can both see it after disabling only Gemini: ~/.agents/.skills.codeg-disabled/pdf # canonical content, not scanned - ~/.codex/skills/pdf -> canonical # Codex still sees it + ~/.cursor/skills/pdf -> canonical # Cursor still sees it ~/.gemini/skills/pdf # absent, so Gemini does not see it ``` From a212be6827270982af08fac1696a906c25e3163d Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:16:04 +0800 Subject: [PATCH 21/23] =?UTF-8?q?fix(skills):=20=E4=BF=AE=E5=A4=8D=20Windo?= =?UTF-8?q?ws=20=E6=8A=80=E8=83=BD=E5=BC=80=E5=85=B3=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/acp.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 9232d6da4c..84d383959c 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -9710,7 +9710,9 @@ fn create_skill_link(source: &Path, destination: &Path) -> std::io::Result<()> { fn remove_skill_link(path: &Path) -> std::io::Result<()> { #[cfg(windows)] if super::experts::path_is_reparse_point(path) && path.is_dir() { - return junction::delete(path); + // `junction::delete` strips the reparse data but leaves an empty + // directory behind. `remove_dir` removes the junction entry itself. + return fs::remove_dir(path); } fs::remove_file(path) } @@ -19193,11 +19195,12 @@ wire_api = "chat" fs::write(skill.join("SKILL.md"), "inline config").unwrap(); fs::create_dir_all(&codex_home).unwrap(); let content_path = fs::canonicalize(skill.join("SKILL.md")).unwrap(); + let encoded_path = + toml_edit::Value::from(content_path.to_string_lossy().as_ref()).to_string(); fs::write( codex_home.join("config.toml"), format!( - "# inline form is valid Codex TOML\n[skills]\nconfig = [{{ path = \"{}\", enabled = true }}]\n", - content_path.display() + "# inline form is valid Codex TOML\n[skills]\nconfig = [{{ path = {encoded_path}, enabled = true }}]\n" ), ) .unwrap(); From 66746bba3ac4fefc9e5bca22cd03133bbfbeb7af Mon Sep 17 00:00:00 2001 From: caiqi <102867723+caiqiqqq@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:23:39 +0800 Subject: [PATCH 22/23] =?UTF-8?q?fix(skills):=20=E6=8C=89=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=84=8F=E8=A7=81=E6=94=B6=E7=B4=A7=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-09-07-agent-skill-switches.md | 35 +- ...2026-09-09-agent-skill-switch-hardening.md | 143 + .../2026-09-07-agent-skill-switches-design.md | 159 +- src-tauri/src/acp/types.rs | 33 +- src-tauri/src/commands/acp.rs | 6560 ++++++++--------- src-tauri/src/web/handlers/acp.rs | 27 +- src-tauri/tests/api_integration.rs | 94 +- .../settings/skills-settings.test.tsx | 54 +- src/components/settings/skills-settings.tsx | 56 +- .../tasks/task-message-composer.test.tsx | 1 + src/hooks/use-agent-skills.test.tsx | 1 + src/i18n/messages.test.ts | 10 +- src/i18n/messages/ar.json | 12 +- src/i18n/messages/de.json | 12 +- src/i18n/messages/en.json | 12 +- src/i18n/messages/es.json | 12 +- src/i18n/messages/fr.json | 12 +- src/i18n/messages/ja.json | 12 +- src/i18n/messages/ko.json | 12 +- src/i18n/messages/pt.json | 12 +- src/i18n/messages/zh-CN.json | 12 +- src/i18n/messages/zh-TW.json | 12 +- src/lib/api-agent-skill-toggle.test.ts | 1 + src/lib/types.ts | 11 + 24 files changed, 3855 insertions(+), 3450 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-09-agent-skill-switch-hardening.md diff --git a/docs/superpowers/plans/2026-09-07-agent-skill-switches.md b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md index 0941c23aca..7d4902e303 100644 --- a/docs/superpowers/plans/2026-09-07-agent-skill-switches.md +++ b/docs/superpowers/plans/2026-09-07-agent-skill-switches.md @@ -1,5 +1,11 @@ # Per-Agent Skill Switches Implementation Plan +> **Superseded:** The shared-root fan-out design in this document must not be +> implemented or restored. It was replaced by +> `2026-09-09-agent-skill-switch-hardening.md`: shared roots are non-toggleable, +> only private roots may use identity-scoped vaults, and project vaults live in +> Codeg's data directory. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add real per-agent availability switches to Settings > Skills without letting a shared skill toggle silently affect another Codeg-managed agent. @@ -272,8 +278,8 @@ Expected: each command exits 0 with no failing tests. - [ ] **Step 4: Review the diff and commit** Run `git diff --check`, inspect `git diff --stat` and `git status --short`, then -commit only the task files on `task/1` without merging, rebasing, or pushing -`main`. +commit only the task files on the current feature branch without merging, +rebasing, or pushing `main`. ### Task 6: Review Fixes For Incoming Links And Cursor Vaults @@ -282,8 +288,9 @@ commit only the task files on `task/1` without merging, rebasing, or pushing **Interfaces:** - Produces: a preflight that enumerates every supported entry in peer active - roots and disabled vaults, then rejects a canonical move if an alias or - same-name entry directly links to that canonical Skill + roots and disabled vaults, follows each link chain through intermediate + targets, then rejects a canonical move if an alias or same-name entry points + into that canonical Skill - Produces: a distinct deterministic vault only for Cursor's exact builtin `~/.cursor/skills-cursor` root, while preserving the legacy vault for `~/.cursor/skills` and custom roots that happen to use the same basename @@ -328,11 +335,14 @@ sibling roots resolve to one vault. - [x] **Step 3: Implement the minimal preflight and vault compatibility fix** Add an entry-level preflight that scans every peer's supported entries in both -active roots and disabled vaults, then uses `skill_link_targets` to identify -direct incoming links. Allow only the exact same-name active links that the -shared restore plan will remove itself; reject aliases, case variants, and -links in a peer's other roots before moving the canonical entry. Invoke the -same preflight from capability calculation and command execution. +active roots and disabled vaults. Index the lexical and resolved identities of +each intermediate and final link target, resolving relative targets from the +link's resolved physical parent and bounding traversal at 40 links. Reject +aliases, case variants, absolute backreferences through the active entry, and +any incomplete link scan before moving the canonical entry. Invoke the same +preflight from capability calculation and command execution. Preserve safe +relative links that remain inside the physical Skill bundle even when the outer +Skill entry is itself a symlink. Keep `.cursor/skills` and all unrelated custom roots on their existing `.skills.codeg-disabled` vaults so disabled entries remain discoverable. Map @@ -359,9 +369,12 @@ Expected: all focused regressions and existing Skill isolation tests pass. Run the frontend checks and every desktop, server, and `codeg-mcp` Rust command listed in `AGENTS.md`, followed by `git diff --check`. Request an independent read-only review of the final commit range, resolve all Critical or Important -findings, and commit the fixes on `task/1`. +findings, and commit the fixes on the current feature branch. The first review found alias/case-variant links outside the exact Skill ID and custom roots named `skills-cursor` were still unsafe. Those cases now have RED/GREEN regressions, and the follow-up review reported no remaining Critical -or Important findings. +or Important findings. The final review also found a two-hop alias blind spot +and incorrect relative-target resolution below symlinked Skill directories; +both cases now have RED/GREEN regressions alongside an unsafe absolute +backreference control. diff --git a/docs/superpowers/plans/2026-09-09-agent-skill-switch-hardening.md b/docs/superpowers/plans/2026-09-09-agent-skill-switch-hardening.md new file mode 100644 index 0000000000..6f76dff490 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-agent-skill-switch-hardening.md @@ -0,0 +1,143 @@ +# Agent Skill Switch Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make per-Agent skill switches match each Agent's real availability state without moving shared or Codeg-managed skills. + +**Architecture:** Codex keeps a declarative `skills.config` implementation and gains complete user, project, plugin, and system discovery. Other Agents may move only entries from private roots into identity-scoped vaults; shared roots and central Codeg skill-pack links are classified but never mutated. Tauri and Axum pass the resolved Codeg data directory to common core functions so project vaults remain outside repositories. + +**Tech Stack:** Rust 2021, Tauri 2, Axum, TOML/toml_edit, Next.js 16, React 19, TypeScript, next-intl, Vitest. + +## Global Constraints + +- Do not merge into, rebase onto, or push `main`. +- Do not move canonical entries from roots consumed by more than one Agent or by external tools. +- Do not move links managed by Experts, Office, Science, or Custom Skills. +- Preserve unknown Codex TOML keys, comments, ordering, and symlinked config targets. +- Use rename-only private toggles and refuse cross-filesystem operations. +- Do not automatically migrate or delete legacy fan-out vaults because they have no ownership manifest. +- Keep the existing frontend request payload stable across Tauri and Axum. + +--- + +### Task 1: Encode Toggle Availability Reasons + +**Files:** +- Modify: `src-tauri/src/acp/types.rs` +- Modify: `src/lib/types.ts` +- Modify: `src/components/settings/skills-settings.tsx` +- Modify: `src/components/settings/skills-settings.test.tsx` +- Modify: `src/i18n/messages/*.json` + +**Interfaces:** +- Produces: `AgentSkillToggleReason` serialized as snake_case. +- Produces: `AgentSkillItem.toggle_reason: Option` and TypeScript mirror `toggle_reason: AgentSkillToggleReason | null`. + +- [x] **Step 1: Add failing Rust serialization and frontend reason-rendering tests.** +- [x] **Step 2: Run `cargo test --features test-utils agent_skill_toggle_reason -- --nocapture` and the focused Skills Settings Vitest; confirm failures are caused by the missing field and messages.** +- [x] **Step 3: Add the enum/field, map every reason to localized copy, and keep `can_toggle` for transport compatibility.** +- [x] **Step 4: Re-run both focused commands and confirm they pass.** + +### Task 2: Replace Fan-Out With Private Root Plans + +**Files:** +- Modify: `src-tauri/src/commands/acp.rs` + +**Interfaces:** +- Produces: `SkillRootTopology`, resolving all configured roots once per request. +- Produces: `SkillRootPlan { active, vault, legacy_vault, shared }` for one Agent and scope. +- Produces: private vault paths containing workspace, Agent, and root identity. + +- [x] **Step 1: Replace fan-out expectations with failing tests asserting `shared_root`, no filesystem mutation, Codeg-managed-link refusal, project vault placement outside the workspace, and distinct vaults for sibling/custom roots.** +- [x] **Step 2: Run the focused `shared_skill`, `managed_skill`, and `project_skill_vault` tests and confirm RED.** +- [x] **Step 3: Implement root plans, active-first listing, exact private moves, same-filesystem checks, collision/link preflight, and read-only legacy discovery. Remove fan-out/link/delete transaction helpers.** +- [x] **Step 4: Re-run focused private/shared tests and confirm GREEN.** + +### Task 3: Pass The Effective Data Directory Through Both Runtimes + +**Files:** +- Modify: `src-tauri/src/commands/acp.rs` +- Modify: `src-tauri/src/web/handlers/acp.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/tests/api_integration.rs` + +**Interfaces:** +- Produces: `_core` list/toggle/read/save/delete functions accepting `data_dir: &Path`. +- Preserves: frontend JSON payloads and Tauri command names. + +- [x] **Step 1: Add failing core and Axum tests proving two data directories cannot see each other's project vaults.** +- [x] **Step 2: Run the focused Rust tests and confirm missing explicit data-directory plumbing is the failure.** +- [x] **Step 3: Add Tauri wrappers using `resolve_effective_data_dir`, Axum handlers using `AppState.data_dir`, and route all operations through the core functions.** +- [x] **Step 4: Run desktop and server `cargo check` plus the focused API test.** + +### Task 4: Match Codex Native Configuration Semantics + +**Files:** +- Modify: `src-tauri/src/commands/acp.rs` + +**Interfaces:** +- Consumes: valid selectors containing exactly one of `path` and `name`. +- Produces: path-only overrides for precise per-skill writes. +- Preserves: mixed/unknown entries and both array-of-tables and inline-array formatting. + +- [x] **Step 1: Add failing tests for mixed selectors, root inline `skills = { config = [...] }`, frontmatter names, and `skills.bundled.enabled = false`.** +- [x] **Step 2: Run the four named tests and verify each fails for its intended semantic mismatch.** +- [x] **Step 3: Implement exactly-one selector matching, path-only updates, root inline-table mutation, frontmatter-name extraction, and bundled-state overlay.** +- [x] **Step 4: Re-run all Codex skill configuration tests and confirm GREEN.** + +### Task 5: Discover Enabled Codex Plugin Skills + +**Files:** +- Modify: `src-tauri/src/commands/acp.rs` + +**Interfaces:** +- Reads: `[plugins."@"].enabled` and cached `.codex-plugin/plugin.json` manifests. +- Produces: read-only, natively toggleable Codex items named `:`. + +- [x] **Step 1: Add a failing temporary-CODEX_HOME test with enabled and disabled plugins, duplicate cache versions, and a namespaced `SKILL.md`.** +- [x] **Step 2: Run the plugin test and confirm no plugin skill is currently discovered.** +- [x] **Step 3: Resolve one active cached manifest per enabled plugin, validate its declared skills directory, namespace item identities, and protect plugin files from save/delete.** +- [x] **Step 4: Re-run plugin and native-toggle tests and confirm GREEN.** + +### Task 6: Remove Per-Skill Topology Rescans And Verify + +**Files:** +- Modify: `src-tauri/src/commands/acp.rs` +- Modify: `docs/superpowers/plans/2026-09-07-agent-skill-switches.md` + +**Interfaces:** +- Preserves: deterministic scope/name ordering and active-first duplicate handling. +- Removes: fan-out planning, peer link creation/deletion, and O(skills x peer-directory-scans) capability checks. + +- [x] **Step 1: Add a regression test that counts topology/root directory reads independently of skill count where practical, and otherwise assert classification consumes a prebuilt topology.** +- [x] **Step 2: Remove obsolete fan-out tests/helpers and mark the old plan's fan-out task as superseded by this plan.** +- [x] **Step 3: Run `cargo fmt --check`, all skill-related Rust tests, focused frontend tests, and `git diff --check`.** +- [x] **Step 4: Run the complete frontend and Rust verification matrix from `AGENTS.md`, inspect the final diff, and commit the remediation on the current feature branch.** + +Verification on 2026-09-09: + +- `cargo test --features test-utils skill -- --nocapture`: 75 Skill-related + tests passed, including the final two-hop alias, physical-parent relative + link, and absolute-backreference regressions. +- `pnpm test`: 428 files and 6132 tests passed. `pnpm build` passed. ESLint + exited successfully with one pre-existing warning in + `src/components/layout/status-bar-mcp.tsx`. +- Desktop `cargo check` and all-target Clippy passed. The complete desktop test + matrix passed with 3572 library tests plus all integration tests after + filtering one pre-existing platform-sensitive Git assertion. +- Server check, Clippy, and library tests passed; 3536 library tests passed + after filtering the same Git assertion. `codeg-mcp` check and Clippy passed. +- `git diff --check` passed. +- `cargo fmt --check` remains red because Rustfmt 1.93 reports repository-wide + formatting drift across untouched files. Running full-repository formatting + would create unrelated churn, so this change does not rewrite those files. +- The filtered test is + `commands::folders::tests::remove_worktree_deletes_a_branch_plain_delete_cannot`. + Apple Git 2.39.3 says `checked out at`, while the existing assertion accepts + only `used by worktree`; `src-tauri/src/commands/folders.rs` is unchanged by + this branch. Running that test alone reproduces the baseline failure. + +Residual platform coverage: Unix symbolic-link behavior is covered locally and +Windows link/reparse handling is covered statically and by Windows-gated tests, +but the final link-chain changes were not exercised on a live Windows volume. +Chains longer than 40 links are intentionally classified as `unsafe_link`. diff --git a/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md index f4b79bd0bf..32c671ac0d 100644 --- a/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md +++ b/docs/superpowers/specs/2026-09-07-agent-skill-switches-design.md @@ -9,13 +9,17 @@ of MCP assignments. An off switch must change what the selected agent can discover. Hiding a skill only from Codeg autocomplete is not sufficient. -## Current Behavior +## Current Behavior And Safety Boundary Codeg discovers skills by scanning each agent's native global or project skill directories. Some directories belong to one agent, such as -`~/.codex/skills`. Others, especially `.agents/skills`, are read by several -agents. The settings list currently exposes only discovered entries and has no -disabled state. +`~/.claude/skills`. Others, especially `.agents/skills`, are read by several +agents and by tools outside Codeg. + +Codeg may only move an entry when its active root belongs to exactly one agent +and scope. A shared root remains owned by the upstream installer: Codeg neither +moves its entries nor creates replacement links in peer roots. This is a +deliberate scope limit, not a transient error. ## User Experience @@ -31,8 +35,14 @@ disabled state. toggled. Codex system skills remain toggleable because Codeg changes only Codex's official availability configuration, not the skill files. - A skill whose shared installation cannot be separated without changing - another configured agent is marked non-toggleable instead of pretending the - operation succeeded. + another consumer is marked non-toggleable with a shared-root explanation. +- Skills linked from Codeg's central skill-pack store are marked as managed by + the Experts, Office, Science, or Custom Skills page. The generic switch never + moves those links. +- Every unavailable switch carries a machine-readable reason so the UI can + distinguish read-only content, a shared root, Codeg-managed content, a + storage collision, an unsafe link, a cross-filesystem move, legacy state, and + a Codex-wide configuration override. - A new or reconnected agent session is required when an already-running agent caches its skill inventory. @@ -45,65 +55,76 @@ each agent so its own discovery result remains the source of truth. Codeg does not move Codex skill files. It reads and atomically updates `CODEX_HOME/config.toml` using Codex's official `skills.config` entries. Rules -are evaluated in file order and the last matching `path` or `name` selector -wins. When a broader or later selector would override the requested state, -Codeg appends a path-specific rule so the requested state is effective without -rewriting the user's broader rule. A repeated toggle updates that trailing -path rule instead of growing the configuration indefinitely. - -This applies to project, user, plugin, and system skills, including skill files -that are read-only. Their files and displayed locations remain unchanged. A -new Codex session is required because an existing session may have cached its -skill inventory. +are evaluated in file order and the last valid matching selector wins. A valid +entry has exactly one selector: `path` or `name`. Entries containing both (or +neither) are preserved but ignored when calculating state, matching current +Codex behavior. When a broader or later `name` selector would override the +requested state, Codeg appends a path-only rule. A repeated toggle updates only +an existing path-only rule instead of modifying a mixed selector or growing the +configuration indefinitely. + +The selector name comes from the `SKILL.md` frontmatter, including the plugin +namespace used by Codex. Enabled plugin manifests under the Codex plugin cache +are included in discovery. `skills.bundled.enabled = false` is authoritative: +bundled system skills are shown disabled and cannot be individually re-enabled +until that global setting is enabled. Project, user, plugin, and system files +remain in place. A new Codex session is required because an existing session +may have cached its skill inventory. ### Filesystem Isolation For Other Agents Agents without a native availability configuration continue to use filesystem isolation because their CLIs scan native skill roots directly. -For each native skill root, Codeg uses a sibling vault that is outside the -agent's scan path: +Each private global root uses a sibling Codeg vault with agent and root +identity. Each private project root uses Codeg's resolved data directory with +workspace, agent, and root identity: ```text -~/.codex/skills/pdf/SKILL.md -~/.codex/.skills.codeg-disabled/pdf/SKILL.md +~/.claude/skills/pdf/SKILL.md +~/.claude/.codeg-skill-vaults/v1/claude_code//pdf/SKILL.md + +/skill-vaults/v1////pdf/SKILL.md ``` Directory skills and flat Markdown skills retain their original entry name and -layout in the vault. Renaming within the same parent filesystem makes a private -skill toggle reversible and preserves all supporting assets and symlink -identity. +layout. Before advertising the switch, Codeg verifies that source and vault are +on the same filesystem; otherwise it returns `cross_filesystem`. Codeg never +falls back to a recursive copy because that would change symlink identity and +weaken crash behavior. ### Private Root -Disabling moves the entry from the native root to its sibling vault. Enabling -moves it back. Destination collisions are rejected before mutation. +Disabling renames the entry from the native root into its exact vault. Enabling +renames it back. Destination collisions and symlinks that would change meaning +after the move are rejected before mutation. A private entry is also rejected +when another configured Agent root contains a directory, Markdown, or +`SKILL.md` link into that entry; moving it would otherwise leave the peer with +a dangling link. Preflight simulates every missing destination-root ancestor +that `create_dir_all` creates, so safe internal and stable absolute links remain +toggleable even before a multi-level vault exists. + +Incoming-link discovery records both lexical and resolved identities for every +link target and each intermediate target in a chain, with traversal bounded at +40 links. A relative target is resolved from the link's resolved physical +parent, not merely from the path used to enter a symlinked directory. This keeps +an internal relative `SKILL.md -> docs/body.md` link valid when the outer Skill +entry is itself a symlink. An absolute link that points back through the active +entry is rejected because moving that entry would break the reference. An +unreadable, dangling, cyclic, or overlong chain makes the topology scan +incomplete, and private moves are then conservatively unavailable. ### Shared Root -Before hiding an entry from a shared root, Codeg identifies every configured -agent that currently relies on that root. For each peer other than the agent -being disabled, it creates a link in an agent-unique native root. The shared -entry is then moved into the shared root's sibling vault and becomes the -canonical link target. - -Example: - -```text -before: - ~/.agents/skills/pdf # Cursor and Gemini can both see it - -after disabling only Gemini: - ~/.agents/.skills.codeg-disabled/pdf # canonical content, not scanned - ~/.cursor/skills/pdf -> canonical # Cursor still sees it - ~/.gemini/skills/pdf # absent, so Gemini does not see it -``` +Shared roots such as global or project `.agents/skills` are listed normally, +but their generic per-agent switch is unavailable with reason `shared_root`. +Codeg leaves both the canonical entry and every peer root unchanged. An agent +that later gains a native declarative availability setting can opt into an +agent-specific implementation without changing this filesystem rule. -If a peer has no unique native skill root, or a conflicting entry blocks a -required link, Codeg rejects the operation before moving the shared source. -This preserves the per-agent contract for all agents managed by Codeg. Tools -outside Codeg that independently consume `.agents/skills` are outside this -assignment model. +Vaults produced by pre-release fan-out builds have no trustworthy ownership +manifest. Codeg may show those entries as `legacy_state` for recovery, but it +does not infer ownership, relink peers, delete them, or move them automatically. ## API And Data Flow @@ -112,13 +133,18 @@ assignment model. ```text enabled: bool can_toggle: bool +toggle_reason: read_only | shared_root | managed_elsewhere | + storage_conflict | unsafe_link | cross_filesystem | + legacy_state | bundled_disabled | config_error | null ``` -For non-Codex agents, the list command scans active roots first and disabled -vaults second. Active entries win when the same ID occurs more than once. This -means a peer link is reported enabled even though its canonical source is held -in a shared vault. For Codex, the list command overlays the effective native -configuration state on every discovered skill and keeps its original path. +For non-Codex agents, the list command scans each active root and its exact +private vault once. Active entries win when the same ID occurs more than once. +Root topology and incoming peer links are indexed once per request rather than +rescanned once per skill. An incomplete peer-link scan conservatively disables +private moves. For Codex, no peer-link scan is needed: the list command overlays +native configuration state on user, project, plugin, and system skills while +retaining each original path. A new command is available over both Tauri and Axum transports: @@ -132,17 +158,20 @@ acp_set_agent_skill_enabled( ) -> AgentSkillItem ``` -Read, save, and delete operations resolve both active and disabled entries so -turning a skill off does not make it unmanageable. Saving a new skill creates -an enabled entry. The frontend invalidates its skill cache after every toggle, -and the generic `useAgentSkills` hook returns enabled entries only. +The resolved Codeg data directory is passed explicitly by both Tauri and Axum +to list, toggle, read, save, and delete core functions. Read, save, and delete +resolve both active and private-vault entries so turning a skill off does not +make it unmanageable. Saving a new skill creates an enabled entry. Plugin, +system, shared legacy, and Codeg-managed content remains protected from writes. +The frontend invalidates its skill cache after every toggle, and the generic +`useAgentSkills` hook returns enabled entries only. ## Consistency And Failure Handling - Skill mutations are serialized inside the backend. -- Validation and destination/link collision checks run before the first move. -- Shared fan-out records links created by the operation. If a later step fails, - those links are removed and a moved source is restored. +- Validation, ownership, filesystem, destination, and link checks run before a + private move. +- Shared roots are never mutated by the availability command. - Repeated enable or disable requests are idempotent. - Symbolic links are moved as links, never followed and copied during private disable operations. @@ -152,12 +181,14 @@ and the generic `useAgentSkills` hook returns enabled entries only. ## Tests -Rust tests cover Codex rule precedence, both supported TOML array forms, -read-only/system-skill availability, stable paths, atomic config writes, and +Rust tests cover Codex selector validity, frontmatter names, root inline tables, +plugin namespaces, bundled disablement, stable paths, atomic config writes, and new-session behavior. They also cover private directory and flat-file toggles, -disabled discovery, idempotency, shared fan-out, peer isolation, collision -refusal, rollback, and non-Codex read-only rejection. Existing skill storage -tests continue to pin each agent's native roots. +project vault placement, root identity, disabled discovery, idempotency, +shared-root refusal without mutation, Codeg-managed links, collision refusal, +incoming directory/content/Markdown links, fresh multi-level vault link +round-trips, cross-filesystem refusal, and non-Codex read-only rejection. +Existing skill storage tests continue to pin each agent's native roots. Frontend tests cover switch state, the exact toggle request, cache invalidation, authoritative reload, disabled autocomplete filtering, read-only/non-toggleable diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index ff55178303..c106c2459d 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1482,7 +1482,7 @@ pub struct AgentDiagnosticsReport { pub plain_text: String, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum AgentSkillScope { Global, @@ -1496,6 +1496,20 @@ pub enum AgentSkillLayout { SkillDirectory, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentSkillToggleReason { + ReadOnly, + SharedRoot, + ManagedElsewhere, + StorageConflict, + UnsafeLink, + CrossFilesystem, + LegacyState, + BundledDisabled, + ConfigError, +} + #[derive(Debug, Clone, Serialize)] pub struct AgentSkillLocation { pub scope: AgentSkillScope, @@ -1512,9 +1526,10 @@ pub struct AgentSkillItem { pub path: String, /// Whether the skill currently lives in an agent-visible skills root. pub enabled: bool, - /// Whether codeg may move the skill between its active root and disabled - /// vault. Built-in CLI skills are visible but cannot be toggled. + /// Whether Codeg can change this skill's availability for this agent. pub can_toggle: bool, + /// Machine-readable explanation when `can_toggle` is false. + pub toggle_reason: Option, /// Best-effort `description:` extracted from the SKILL.md YAML /// frontmatter. `None` when there is no frontmatter or no key. pub description: Option, @@ -1569,6 +1584,18 @@ pub struct ForkResultInfo { mod envelope_tests { use super::*; + #[test] + fn agent_skill_toggle_reason_serializes_as_snake_case() { + assert_eq!( + serde_json::to_value(AgentSkillToggleReason::ManagedElsewhere).unwrap(), + serde_json::json!("managed_elsewhere") + ); + assert_eq!( + serde_json::to_value(AgentSkillToggleReason::CrossFilesystem).unwrap(), + serde_json::json!("cross_filesystem") + ); + } + #[test] fn event_envelope_serializes_with_flat_payload() { let env = EventEnvelope { diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 84d383959c..fa182712dd 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -1,10 +1,11 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; #[cfg(feature = "tauri-runtime")] use tauri::{Manager, State}; @@ -17,10 +18,10 @@ use crate::acp::preflight::{self, PreflightResult}; use crate::acp::registry; use crate::acp::types::{ AcpAgentInfo, AgentDiagnosticsReport, AgentSkillContent, AgentSkillItem, AgentSkillLayout, - AgentSkillLocation, AgentSkillScope, AgentSkillsListResult, CodexGranularApproval, - CodexSandboxSettings, CodexSandboxStructuredConfig, CodexWorkspaceWrite, ConfigStaleKind, - ConnectionStatus, DiagCheck, DiagLevel, DiagSection, DiagnosticsVerdict, GrokSettings, - GrokStructuredConfig, + AgentSkillLocation, AgentSkillScope, AgentSkillToggleReason, AgentSkillsListResult, + CodexGranularApproval, CodexSandboxSettings, CodexSandboxStructuredConfig, CodexWorkspaceWrite, + ConfigStaleKind, ConnectionStatus, DiagCheck, DiagLevel, DiagSection, DiagnosticsVerdict, + GrokSettings, GrokStructuredConfig, }; #[cfg(feature = "tauri-runtime")] use crate::acp::types::{ConnectionInfo, ForkResultInfo, PromptInputBlock}; @@ -8318,6 +8319,32 @@ fn skill_name_from_id(id: &str) -> String { id.to_string() } +fn read_skill_frontmatter_name(content_path: &Path) -> Option { + use std::io::Read; + + let mut file = fs::File::open(content_path).ok()?; + let mut buf = [0u8; 4096]; + let n = file.read(&mut buf).ok()?; + let head = std::str::from_utf8(&buf[..n]).ok()?; + let mut lines = head.lines(); + if lines.next()?.trim() != "---" { + return None; + } + for line in lines { + let trimmed_end = line.trim_end(); + if trimmed_end == "---" || trimmed_end == "..." { + break; + } + if line.starts_with(|c: char| c.is_whitespace()) { + continue; + } + if let Some(rest) = line.strip_prefix("name:") { + return parse_frontmatter_scalar(rest); + } + } + None +} + /// Best-effort extraction of a one-line skill description from a markdown /// file's YAML frontmatter. Prefers `short-description` (commonly nested under /// a `metadata:` block) and falls back to a top-level `description`. Only the @@ -8394,15 +8421,19 @@ fn build_skill_item( path: PathBuf, enabled: bool, ) -> AgentSkillItem { - let description = read_skill_description(&skill_content_path(layout, &path)); + let content_path = skill_content_path(layout, &path); + let description = read_skill_description(&content_path); + let name = + read_skill_frontmatter_name(&content_path).unwrap_or_else(|| skill_name_from_id(&id)); AgentSkillItem { - name: skill_name_from_id(&id), + name, id, scope, layout, path: path.to_string_lossy().to_string(), enabled, can_toggle: true, + toggle_reason: None, description, read_only: false, } @@ -8436,6 +8467,7 @@ fn apply_skill_capabilities(agent_type: AgentType, skill: &mut AgentSkillItem) { if is_read_only_skill_path(agent_type, Path::new(&skill.path)) { skill.read_only = true; skill.can_toggle = false; + skill.toggle_reason = Some(AgentSkillToggleReason::ReadOnly); } } @@ -8527,6 +8559,26 @@ fn list_skills_from_dir_with_state( dir: &Path, kind: SkillStorageKind, enabled: bool, +) -> Result, AcpError> { + let mut by_id: BTreeMap = BTreeMap::new(); + for skill in scan_skills_from_dir_with_state(scope, dir, kind, enabled)? { + match skill.layout { + AgentSkillLayout::SkillDirectory => { + by_id.insert(skill.id.clone(), skill); + } + AgentSkillLayout::MarkdownFile => { + by_id.entry(skill.id.clone()).or_insert(skill); + } + } + } + Ok(by_id.into_values().collect()) +} + +fn scan_skills_from_dir_with_state( + scope: AgentSkillScope, + dir: &Path, + kind: SkillStorageKind, + enabled: bool, ) -> Result, AcpError> { if !dir.exists() { return Ok(Vec::new()); @@ -8535,7 +8587,7 @@ fn list_skills_from_dir_with_state( let entries = fs::read_dir(dir) .map_err(|e| AcpError::protocol(format!("failed to read skills directory: {e}")))?; - let mut by_id: BTreeMap = BTreeMap::new(); + let mut skills = Vec::new(); for entry in entries { let entry = match entry { Ok(value) => value, @@ -8547,16 +8599,13 @@ fn list_skills_from_dir_with_state( match skill_entry_layout(&path, kind) { Some(AgentSkillLayout::SkillDirectory) => { - by_id.insert( - id.clone(), - build_skill_item( - id, - scope, - AgentSkillLayout::SkillDirectory, - path, - enabled, - ), - ); + skills.push(build_skill_item( + id, + scope, + AgentSkillLayout::SkillDirectory, + path, + enabled, + )); } Some(AgentSkillLayout::MarkdownFile) => { let stem = path @@ -8564,25 +8613,19 @@ fn list_skills_from_dir_with_state( .and_then(|s| s.to_str()) .map(str::to_string) .unwrap_or_else(|| id.clone()); - if by_id.contains_key(&stem) { - continue; - } - by_id.insert( - stem.clone(), - build_skill_item( - stem, - scope, - AgentSkillLayout::MarkdownFile, - path, - enabled, - ), - ); + skills.push(build_skill_item( + stem, + scope, + AgentSkillLayout::MarkdownFile, + path, + enabled, + )); } None => {} } } - - Ok(by_id.into_values().collect()) + skills.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(skills) } pub(crate) fn disabled_skill_root(active_root: &Path) -> PathBuf { @@ -8599,28 +8642,84 @@ pub(crate) fn disabled_skill_root(active_root: &Path) -> PathBuf { .join(vault_name) } -pub(crate) fn list_skills_from_roots( +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillResidence { + Active, + PrivateVault, + LegacyVault, +} + +#[derive(Debug, Clone)] +struct PlannedSkill { + item: AgentSkillItem, + plan_index: usize, + residence: SkillResidence, +} + +#[derive(Debug, Default)] +struct SkillInventory { + listed: Vec, + active_by_id: BTreeMap>, +} + +fn list_skills_from_plans( scope: AgentSkillScope, - roots: &[PathBuf], + plans: &[SkillRootPlan], kind: SkillStorageKind, -) -> Result, AcpError> { - let mut by_id = BTreeMap::new(); - - // Scan every active root before considering any vault so an active copy - // wins even when its root follows the vault-owning root in precedence. - for root in roots { - for skill in list_skills_from_dir_with_state(scope, root, kind, true)? { - by_id.entry(skill.id.clone()).or_insert(skill); - } - } - for root in roots { - let vault = disabled_skill_root(root); - for skill in list_skills_from_dir_with_state(scope, &vault, kind, false)? { - by_id.entry(skill.id.clone()).or_insert(skill); +) -> Result { + let mut by_id: BTreeMap = BTreeMap::new(); + let mut active_by_id: BTreeMap> = BTreeMap::new(); + + for residence in [ + SkillResidence::Active, + SkillResidence::PrivateVault, + SkillResidence::LegacyVault, + ] { + let mut seen_roots = HashSet::new(); + for (plan_index, plan) in plans.iter().enumerate() { + let (dir, enabled) = match residence { + SkillResidence::Active => (&plan.active, true), + SkillResidence::PrivateVault => (&plan.vault, false), + SkillResidence::LegacyVault => (&plan.legacy_vault, false), + }; + let resolved_dir = match residence { + SkillResidence::Active => plan.resolved_active.clone(), + SkillResidence::PrivateVault => plan.resolved_vault.clone(), + SkillResidence::LegacyVault => resolved_skill_root(&plan.legacy_vault)?, + }; + if !seen_roots.insert(resolved_dir) { + continue; + } + for skill in scan_skills_from_dir_with_state(scope, dir, kind, enabled)? { + if residence == SkillResidence::Active { + active_by_id + .entry(skill.id.clone()) + .or_default() + .push(skill.clone()); + } + by_id.entry(skill.id.clone()).or_insert(PlannedSkill { + item: skill, + plan_index, + residence, + }); + } } } - Ok(by_id.into_values().collect()) + Ok(SkillInventory { + listed: by_id.into_values().collect(), + active_by_id, + }) +} + +fn locate_skill_in_inventory<'a>( + inventory: &'a SkillInventory, + skill_id: &str, +) -> Option<&'a PlannedSkill> { + inventory + .listed + .iter() + .find(|skill| skill.item.id == skill_id) } fn locate_existing_skill( @@ -8683,59 +8782,11 @@ pub(crate) fn locate_existing_skill_across_dirs( None } -fn active_skill_entries( - roots: &[PathBuf], - kind: SkillStorageKind, - skill_id: &str, - scope: AgentSkillScope, -) -> Result, AcpError> { - let mut matches = Vec::new(); - let mut seen_roots = std::collections::HashSet::new(); - for root in roots { - if !seen_roots.insert(resolved_skill_root(root)?) { - continue; - } - let entries = match fs::read_dir(root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(AcpError::protocol(format!( - "failed to inspect active skills directory '{}': {error}", - root.display() - ))) - } - }; - for entry in entries { - let path = entry - .map_err(|error| { - AcpError::protocol(format!("failed to inspect active skill: {error}")) - })? - .path(); - let Some(layout) = skill_entry_layout(&path, kind) else { - continue; - }; - let id = match layout { - AgentSkillLayout::SkillDirectory => path.file_name(), - AgentSkillLayout::MarkdownFile => path.file_stem(), - } - .and_then(|name| name.to_str()); - if id == Some(skill_id) { - matches.push(build_skill_item( - skill_id.to_string(), - scope, - layout, - path, - true, - )); - } - } - } - Ok(matches) -} - -#[derive(Debug, Default)] +#[derive(Debug)] struct CodexSkillConfig { entries: Vec, + bundled_enabled: bool, + enabled_plugins: Vec, } #[derive(Debug)] @@ -8745,6 +8796,22 @@ struct CodexSkillConfigEntry { enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexPluginRef { + name: String, + marketplace: String, +} + +impl Default for CodexSkillConfig { + fn default() -> Self { + Self { + entries: Vec::new(), + bundled_enabled: true, + enabled_plugins: Vec::new(), + } + } +} + fn resolve_codex_skill_config_path(raw: &str, codex_home: &Path) -> PathBuf { if raw == "~" { return home_dir_or_default(); @@ -8788,65 +8855,115 @@ fn parse_codex_skill_config( let root = raw_toml .parse::() .map_err(|error| AcpError::protocol(format!("invalid codex config.toml: {error}")))?; - let Some(config) = root - .get("skills") + let skills = root.get("skills").and_then(toml::Value::as_table); + let bundled_enabled = skills + .and_then(|skills| skills.get("bundled")) .and_then(toml::Value::as_table) - .and_then(|skills| skills.get("config")) - else { - return Ok(CodexSkillConfig::default()); - }; - let config = config.as_array().ok_or_else(|| { - AcpError::protocol("invalid codex config.toml: skills.config must be an array") - })?; - let mut entries = Vec::with_capacity(config.len()); - for entry in config { - let table = entry.as_table().ok_or_else(|| { - AcpError::protocol("invalid codex config.toml: skills.config entry must be a table") - })?; - let path = match table.get("path") { - Some(value) => Some( - value - .as_str() - .map(|path| resolve_codex_skill_config_path(path, codex_home)) - .ok_or_else(|| { - AcpError::protocol( - "invalid codex config.toml: skills.config path must be a string", - ) - })?, - ), - None => None, - }; - let name = match table.get("name") { - Some(value) => Some(value.as_str().map(str::to_string).ok_or_else(|| { - AcpError::protocol("invalid codex config.toml: skills.config name must be a string") - })?), - None => None, - }; - let enabled = table - .get("enabled") - .ok_or_else(|| { + .and_then(|bundled| bundled.get("enabled")) + .map(|enabled| { + enabled.as_bool().ok_or_else(|| { AcpError::protocol( - "invalid codex config.toml: skills.config enabled is required", - ) - })? - .as_bool() - .ok_or_else(|| { - AcpError::protocol( - "invalid codex config.toml: skills.config enabled must be a boolean", + "invalid codex config.toml: skills.bundled.enabled must be a boolean", ) + }) + }) + .transpose()? + .unwrap_or(true); + + let mut entries = Vec::new(); + if let Some(config) = skills.and_then(|skills| skills.get("config")) { + let config = config.as_array().ok_or_else(|| { + AcpError::protocol("invalid codex config.toml: skills.config must be an array") + })?; + entries.reserve(config.len()); + for entry in config { + let table = entry.as_table().ok_or_else(|| { + AcpError::protocol("invalid codex config.toml: skills.config entry must be a table") })?; - entries.push(CodexSkillConfigEntry { - path, - name, - enabled, - }); + let path = match table.get("path") { + Some(value) => Some( + value + .as_str() + .map(|path| resolve_codex_skill_config_path(path, codex_home)) + .ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config path must be a string", + ) + })?, + ), + None => None, + }; + let name = match table.get("name") { + Some(value) => Some(value.as_str().map(str::to_string).ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config name must be a string", + ) + })?), + None => None, + }; + let enabled = table + .get("enabled") + .ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config enabled is required", + ) + })? + .as_bool() + .ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: skills.config enabled must be a boolean", + ) + })?; + entries.push(CodexSkillConfigEntry { + path, + name, + enabled, + }); + } + } + + let mut enabled_plugins = Vec::new(); + if let Some(plugins) = root.get("plugins").and_then(toml::Value::as_table) { + for (qualified_name, value) in plugins { + let enabled = value + .as_table() + .and_then(|plugin| plugin.get("enabled")) + .and_then(toml::Value::as_bool) + .unwrap_or(false); + if !enabled { + continue; + } + let Some((name, marketplace)) = qualified_name.rsplit_once('@') else { + continue; + }; + if !name.is_empty() && !marketplace.is_empty() { + enabled_plugins.push(CodexPluginRef { + name: name.to_string(), + marketplace: marketplace.to_string(), + }); + } + } } - Ok(CodexSkillConfig { entries }) + enabled_plugins.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.marketplace.cmp(&right.marketplace)) + }); + enabled_plugins.dedup(); + + Ok(CodexSkillConfig { + entries, + bundled_enabled, + enabled_plugins, + }) } impl CodexSkillConfig { fn skill_enabled(&self, path: &Path, name: &str) -> bool { self.entries.iter().fold(true, |enabled, entry| { + if entry.path.is_some() == entry.name.is_some() { + return enabled; + } let path_matches = entry .path .as_deref() @@ -8866,13 +8983,101 @@ fn codex_skill_entries_enabled( config: &CodexSkillConfig, ) -> Result { for skill in entries { - if config.skill_enabled(&absolute_skill_content_path(skill)?, &skill.id) { + if config.skill_enabled(&absolute_skill_content_path(skill)?, &skill.name) { return Ok(true); } } Ok(false) } +fn update_codex_array_of_tables( + config: &mut toml_edit::ArrayOfTables, + codex_home: &Path, + skill_name: &str, + paths: &[PathBuf], + enabled: bool, +) { + for path in paths { + let mut last_matching_path = None; + for (index, entry) in config.iter().enumerate() { + let has_path = entry.get("path").is_some(); + let has_name = entry.get("name").is_some(); + if has_path == has_name { + continue; + } + let path_matches = entry + .get("path") + .and_then(toml_edit::Item::as_str) + .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) + .is_some_and(|configured| same_skill_config_path(&configured, path)); + let name_matches = + entry.get("name").and_then(toml_edit::Item::as_str) == Some(skill_name); + if path_matches || name_matches { + last_matching_path = path_matches.then_some(index); + } + } + if let Some(index) = last_matching_path { + config + .get_mut(index) + .expect("matching Codex skill config entry must exist") + .insert("enabled", toml_edit::value(enabled)); + } else { + let mut entry = toml_edit::Table::new(); + entry.insert("path", toml_edit::value(path.to_string_lossy().as_ref())); + entry.insert("enabled", toml_edit::value(enabled)); + config.push(entry); + } + } +} + +fn update_codex_inline_array( + config: &mut toml_edit::Array, + codex_home: &Path, + skill_name: &str, + paths: &[PathBuf], + enabled: bool, +) -> Result<(), AcpError> { + for path in paths { + let mut last_matching_path = None; + for (index, value) in config.iter().enumerate() { + let entry = value.as_inline_table().ok_or_else(|| { + AcpError::protocol("invalid codex config.toml: skills.config entry must be a table") + })?; + let has_path = entry.get("path").is_some(); + let has_name = entry.get("name").is_some(); + if has_path == has_name { + continue; + } + let path_matches = entry + .get("path") + .and_then(toml_edit::Value::as_str) + .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) + .is_some_and(|configured| same_skill_config_path(&configured, path)); + let name_matches = + entry.get("name").and_then(toml_edit::Value::as_str) == Some(skill_name); + if path_matches || name_matches { + last_matching_path = path_matches.then_some(index); + } + } + if let Some(index) = last_matching_path { + config + .get_mut(index) + .and_then(toml_edit::Value::as_inline_table_mut) + .expect("matching Codex skill config entry must be an inline table") + .insert("enabled", toml_edit::Value::from(enabled)); + } else { + let mut entry = toml_edit::InlineTable::new(); + entry.insert( + "path", + toml_edit::Value::from(path.to_string_lossy().as_ref()), + ); + entry.insert("enabled", toml_edit::Value::from(enabled)); + config.push(toml_edit::Value::InlineTable(entry)); + } + } + Ok(()) +} + fn apply_codex_skill_enabled_config( base_toml: &str, codex_home: &Path, @@ -8884,19 +9089,6 @@ fn apply_codex_skill_enabled_config( let mut doc = base_toml .parse::() .map_err(|error| AcpError::protocol(format!("invalid codex config.toml: {error}")))?; - if doc.get("skills").is_none() { - doc["skills"] = toml_edit::Item::Table(toml_edit::Table::new()); - } - let skills = doc - .get_mut("skills") - .and_then(toml_edit::Item::as_table_mut) - .ok_or_else(|| AcpError::protocol("invalid codex config.toml: skills must be a table"))?; - if skills.get("config").is_none() { - skills.insert( - "config", - toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()), - ); - } // Match Codex's native `skills/config/write`: persist the resolved // SKILL.md target so symlink aliases collapse to one stable rule. let mut unique_paths = paths @@ -8906,141 +9098,186 @@ fn apply_codex_skill_enabled_config( unique_paths.sort(); unique_paths.dedup(); - let config = skills - .get_mut("config") - .ok_or_else(|| AcpError::protocol("invalid codex config.toml: missing skills.config"))?; - // Codex applies matching rules in order. Update an existing path rule only - // when it is the final match; otherwise append a path-specific override. - match config { - toml_edit::Item::ArrayOfTables(config) => { - for path in unique_paths { - let mut last_matching_path = None; - for (index, entry) in config.iter().enumerate() { - let path_matches = entry - .get("path") - .and_then(toml_edit::Item::as_str) - .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) - .is_some_and(|configured| same_skill_config_path(&configured, &path)); - let name_matches = - entry.get("name").and_then(toml_edit::Item::as_str) == Some(skill_name); - if path_matches || name_matches { - last_matching_path = path_matches.then_some(index); - } + if doc.get("skills").is_none() { + doc["skills"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + let skills = doc + .get_mut("skills") + .ok_or_else(|| AcpError::protocol("invalid codex config.toml: missing skills"))?; + match skills { + toml_edit::Item::Table(skills) => { + if skills.get("config").is_none() { + skills.insert( + "config", + toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()), + ); + } + match skills + .get_mut("config") + .expect("skills.config inserted above") + { + toml_edit::Item::ArrayOfTables(config) => update_codex_array_of_tables( + config, + codex_home, + skill_name, + &unique_paths, + enabled, + ), + toml_edit::Item::Value(toml_edit::Value::Array(config)) => { + update_codex_inline_array( + config, + codex_home, + skill_name, + &unique_paths, + enabled, + )?; } - if let Some(index) = last_matching_path { - config - .get_mut(index) - .expect("matching Codex skill config entry must exist") - .insert("enabled", toml_edit::value(enabled)); - } else { - let mut entry = toml_edit::Table::new(); - entry.insert("path", toml_edit::value(path.to_string_lossy().as_ref())); - entry.insert("enabled", toml_edit::value(enabled)); - config.push(entry); + _ => { + return Err(AcpError::protocol( + "invalid codex config.toml: skills.config must be an array of tables", + )) } } } - toml_edit::Item::Value(toml_edit::Value::Array(config)) => { - for path in unique_paths { - let mut last_matching_path = None; - for (index, value) in config.iter().enumerate() { - let entry = value.as_inline_table().ok_or_else(|| { - AcpError::protocol( - "invalid codex config.toml: skills.config entry must be a table", - ) - })?; - let path_matches = entry - .get("path") - .and_then(toml_edit::Value::as_str) - .map(|configured| resolve_codex_skill_config_path(configured, codex_home)) - .is_some_and(|configured| same_skill_config_path(&configured, &path)); - let name_matches = - entry.get("name").and_then(toml_edit::Value::as_str) == Some(skill_name); - if path_matches || name_matches { - last_matching_path = path_matches.then_some(index); - } - } - if let Some(index) = last_matching_path { - config - .get_mut(index) - .and_then(toml_edit::Value::as_inline_table_mut) - .expect("matching Codex skill config entry must be an inline table") - .insert("enabled", toml_edit::Value::from(enabled)); - } else { - let mut entry = toml_edit::InlineTable::new(); - entry.insert( - "path", - toml_edit::Value::from(path.to_string_lossy().as_ref()), - ); - entry.insert("enabled", toml_edit::Value::from(enabled)); - config.push(toml_edit::Value::InlineTable(entry)); - } + toml_edit::Item::Value(toml_edit::Value::InlineTable(skills)) => { + if skills.get("config").is_none() { + skills.insert("config", toml_edit::Value::Array(toml_edit::Array::new())); } + let config = skills + .get_mut("config") + .and_then(toml_edit::Value::as_array_mut) + .ok_or_else(|| { + AcpError::protocol( + "invalid codex config.toml: inline skills.config must be an array", + ) + })?; + update_codex_inline_array(config, codex_home, skill_name, &unique_paths, enabled)?; } _ => { return Err(AcpError::protocol( - "invalid codex config.toml: skills.config must be an array of tables", + "invalid codex config.toml: skills must be a table", )) } } Ok(doc.to_string()) } -fn set_codex_skill_enabled_native( - mut listed: AgentSkillItem, - active: &[AgentSkillItem], - enabled: bool, -) -> Result { - let _config_guard = lock_codex_config_mutation()?; - let codex_home = codex_home_dir(); - let base = read_codex_config_or_empty()?; - let current = parse_codex_skill_config(&base, &codex_home)?; - listed.enabled = codex_skill_entries_enabled(active, ¤t)?; - listed.can_toggle = true; - if listed.enabled == enabled { - return Ok(listed); - } - let paths = active - .iter() - .map(absolute_skill_content_path) - .collect::, _>>()?; - let next = apply_codex_skill_enabled_config(&base, &codex_home, &listed.id, &paths, enabled)?; - let updated = parse_codex_skill_config(&next, &codex_home)?; - if codex_skill_entries_enabled(active, &updated)? != enabled { - return Err(AcpError::protocol( - "requested Codex skill state was not reached", - )); - } - persist_codex_native_config_files_unlocked(None, Some(&next))?; - listed.enabled = enabled; - Ok(listed) -} +fn remove_codex_disabled_skill_path_rules( + base_toml: &str, + codex_home: &Path, + paths: &[PathBuf], +) -> Result, AcpError> { + parse_codex_skill_config(base_toml, codex_home)?; + let mut doc = base_toml + .parse::() + .map_err(|error| AcpError::protocol(format!("invalid codex config.toml: {error}")))?; + let matches_path = |raw: &str| { + let configured = resolve_codex_skill_config_path(raw, codex_home); + paths + .iter() + .any(|path| same_skill_config_path(&configured, path)) + }; + let Some(skills) = doc.get_mut("skills") else { + return Ok(None); + }; + let mut removed = false; -fn apply_codex_native_skill_state( - agent_type: AgentType, - roots: &[PathBuf], - kind: SkillStorageKind, - skill: &mut AgentSkillItem, -) { - if agent_type != AgentType::Codex || !skill.enabled { - return; - } - let state = active_skill_entries(roots, kind, &skill.id, skill.scope).and_then(|active| { - if active.is_empty() { - return Err(AcpError::protocol("active Codex skill entry not found")); + match skills { + toml_edit::Item::Table(skills) => { + let Some(config) = skills.get_mut("config") else { + return Ok(None); + }; + match config { + toml_edit::Item::ArrayOfTables(config) => config.retain(|entry| { + let remove = entry.get("name").is_none() + && entry + .get("path") + .and_then(toml_edit::Item::as_str) + .is_some_and(&matches_path) + && entry.get("enabled").and_then(toml_edit::Item::as_bool) == Some(false); + removed |= remove; + !remove + }), + toml_edit::Item::Value(toml_edit::Value::Array(config)) => { + config.retain(|value| { + let remove = value.as_inline_table().is_some_and(|entry| { + entry.get("name").is_none() + && entry + .get("path") + .and_then(toml_edit::Value::as_str) + .is_some_and(&matches_path) + && entry.get("enabled").and_then(toml_edit::Value::as_bool) + == Some(false) + }); + removed |= remove; + !remove + }); + } + _ => { + return Err(AcpError::protocol( + "invalid codex config.toml: skills.config must be an array of tables", + )) + } + } } - let codex_home = codex_home_dir(); - let raw = read_codex_config_or_empty()?; - let config = parse_codex_skill_config(&raw, &codex_home)?; - codex_skill_entries_enabled(&active, &config) - }); - match state { - Ok(enabled) => { - skill.enabled = enabled; - skill.can_toggle = true; + toml_edit::Item::Value(toml_edit::Value::InlineTable(skills)) => { + let Some(config) = skills + .get_mut("config") + .and_then(toml_edit::Value::as_array_mut) + else { + return Ok(None); + }; + config.retain(|value| { + let remove = value.as_inline_table().is_some_and(|entry| { + entry.get("name").is_none() + && entry + .get("path") + .and_then(toml_edit::Value::as_str) + .is_some_and(&matches_path) + && entry.get("enabled").and_then(toml_edit::Value::as_bool) == Some(false) + }); + removed |= remove; + !remove + }); + } + _ => { + return Err(AcpError::protocol( + "invalid codex config.toml: skills must be a table", + )) } - Err(_) => skill.can_toggle = false, } + + Ok(removed.then(|| doc.to_string())) +} + +fn set_codex_skill_enabled_native( + mut listed: AgentSkillItem, + active: &[AgentSkillItem], + enabled: bool, +) -> Result { + let _config_guard = lock_codex_config_mutation()?; + let codex_home = codex_home_dir(); + let base = read_codex_config_or_empty()?; + let current = parse_codex_skill_config(&base, &codex_home)?; + listed.enabled = codex_skill_entries_enabled(active, ¤t)?; + listed.can_toggle = true; + if listed.enabled == enabled { + return Ok(listed); + } + let paths = active + .iter() + .map(absolute_skill_content_path) + .collect::, _>>()?; + let next = apply_codex_skill_enabled_config(&base, &codex_home, &listed.name, &paths, enabled)?; + let updated = parse_codex_skill_config(&next, &codex_home)?; + if codex_skill_entries_enabled(active, &updated)? != enabled { + return Err(AcpError::protocol( + "requested Codex skill state was not reached", + )); + } + persist_codex_native_config_files_unlocked(None, Some(&next))?; + listed.enabled = enabled; + Ok(listed) } // All settings mutations share this lock so lookup, preflight and mutation @@ -9120,9 +9357,13 @@ fn resolve_skill_path_after_move( ) { continue; } - // The sibling root may be created by the move, but no other missing - // path is treated as present during preflight. - if Some(resolved.as_path()) == destination.parent() { + // create_dir_all materializes the whole destination root before the + // rename. resolved_skill_root has already resolved every existing + // ancestor, so simulate the still-missing suffix without probing it. + if destination + .parent() + .is_some_and(|parent| parent.starts_with(&resolved)) + { continue; } let physical = if resolved == destination { @@ -9190,6 +9431,13 @@ fn preflight_skill_symlink_move(source: &Path, destination_root: &Path) -> Resul while let Some(entry) = pending.pop() { let metadata = fs::symlink_metadata(&entry) .map_err(|e| AcpError::protocol(format!("failed to inspect skill entry: {e}")))?; + #[cfg(windows)] + if !metadata.file_type().is_symlink() && super::experts::path_is_reparse_point(&entry) { + return Err(AcpError::protocol(format!( + "skill junction or reparse point '{}' cannot be moved safely", + entry.display() + ))); + } if metadata.file_type().is_symlink() { let target = fs::read_link(&entry) .map_err(|e| AcpError::protocol(format!("failed to read skill symlink: {e}")))?; @@ -9240,24 +9488,190 @@ fn preflight_skill_symlink_move(source: &Path, destination_root: &Path) -> Resul Ok(()) } -/// The caller must hold SKILL_MUTATION_LOCK when serving a backend command. -fn set_private_skill_enabled( +fn nearest_existing_metadata(path: &Path) -> Result { + let mut current = path; + loop { + match fs::metadata(current) { + Ok(metadata) => return Ok(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + current = current.parent().ok_or_else(|| { + AcpError::protocol(format!( + "failed to find an existing ancestor for '{}'", + path.display() + )) + })?; + } + Err(error) => { + return Err(AcpError::protocol(format!( + "failed to inspect filesystem for '{}': {error}", + current.display() + ))) + } + } + } +} + +fn skill_move_is_same_filesystem(source: &Path, destination_root: &Path) -> Result { + let source_parent = source + .parent() + .ok_or_else(|| AcpError::protocol("skill entry has no parent"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Ok(nearest_existing_metadata(source_parent)?.dev() + == nearest_existing_metadata(destination_root)?.dev()) + } + + #[cfg(windows)] + { + use std::path::Component; + + let prefix = |path: &Path| { + resolved_skill_root(path) + .ok()? + .components() + .find_map(|component| match component { + Component::Prefix(prefix) => { + Some(prefix.as_os_str().to_string_lossy().to_lowercase()) + } + _ => None, + }) + }; + Ok(prefix(source_parent) + .zip(prefix(destination_root)) + .is_some_and(|(source, destination)| source == destination)) + } + + #[cfg(not(any(unix, windows)))] + { + let _ = (source_parent, destination_root); + Ok(true) + } +} + +fn is_codeg_managed_skill_at(skill: &AgentSkillItem, central_root: &Path) -> bool { + fs::canonicalize(&skill.path) + .ok() + .zip(fs::canonicalize(central_root).ok()) + .is_some_and(|(path, central)| path.starts_with(central)) +} + +fn is_codeg_managed_skill(skill: &AgentSkillItem) -> bool { + is_codeg_managed_skill_at(skill, &super::experts::central_experts_dir()) +} + +fn skill_resolves_to_legacy_vault(skill: &AgentSkillItem, topology: &SkillRootTopology) -> bool { + fs::canonicalize(&skill.path) + .ok() + .is_some_and(|path| topology.path_is_in_legacy_vault(&path)) +} + +fn classify_non_codex_skill( + planned: &PlannedSkill, + plan: &SkillRootPlan, + inventory: &SkillInventory, + topology: &SkillRootTopology, +) -> Option { + let skill = &planned.item; + if is_codeg_managed_skill(skill) { + return Some(AgentSkillToggleReason::ManagedElsewhere); + } + if planned.residence == SkillResidence::LegacyVault + || skill_resolves_to_legacy_vault(skill, topology) + { + return Some(AgentSkillToggleReason::LegacyState); + } + if skill.read_only { + return Some(AgentSkillToggleReason::ReadOnly); + } + if plan.shared { + return Some(AgentSkillToggleReason::SharedRoot); + } + if inventory + .active_by_id + .get(&skill.id) + .is_some_and(|entries| entries.len() > 1) + || plan.vault_conflict + { + return Some(AgentSkillToggleReason::StorageConflict); + } + + let source = Path::new(&skill.path); + if topology.has_external_incoming_link(source) { + return Some(AgentSkillToggleReason::UnsafeLink); + } + let destination_root = if skill.enabled { + &plan.vault + } else { + &plan.active + }; + if !skill_root_writable(source.parent().unwrap_or(&plan.active)) + || !skill_root_writable(destination_root) + { + return Some(AgentSkillToggleReason::ReadOnly); + } + if !skill_move_is_same_filesystem(source, destination_root).unwrap_or(false) { + return Some(AgentSkillToggleReason::CrossFilesystem); + } + if preflight_skill_destination(destination_root, &skill.id).is_err() { + return Some(AgentSkillToggleReason::StorageConflict); + } + if preflight_skill_symlink_move(source, destination_root).is_err() { + return Some(AgentSkillToggleReason::UnsafeLink); + } + None +} + +fn set_skill_unavailable(skill: &mut AgentSkillItem, reason: AgentSkillToggleReason) { + skill.can_toggle = false; + skill.toggle_reason = Some(reason); +} + +fn toggle_reason_error(id: &str, reason: AgentSkillToggleReason) -> AcpError { + let explanation = match reason { + AgentSkillToggleReason::ReadOnly => "its files are read-only", + AgentSkillToggleReason::SharedRoot => { + "it is installed in a shared skill root and cannot be changed for one agent" + } + AgentSkillToggleReason::ManagedElsewhere => "it is managed by another Codeg skill page", + AgentSkillToggleReason::StorageConflict => "its storage has a conflicting entry", + AgentSkillToggleReason::UnsafeLink => "moving it would change or break a link", + AgentSkillToggleReason::CrossFilesystem => { + "its active root and disabled vault are on different filesystems" + } + AgentSkillToggleReason::LegacyState => { + "it uses a legacy disabled layout that requires manual recovery" + } + AgentSkillToggleReason::BundledDisabled => { + "bundled skills are disabled by the global Codex configuration" + } + AgentSkillToggleReason::ConfigError => { + "the agent availability configuration could not be read safely" + } + }; + AcpError::protocol(format!( + "skill '{id}' cannot be toggled because {explanation}" + )) +} + +fn set_private_skill_enabled_at( root: &Path, + vault: &Path, kind: SkillStorageKind, scope: AgentSkillScope, skill_id: &str, enabled: bool, ) -> Result { let id = validate_skill_id(skill_id)?; - let roots = [root.to_path_buf()]; - let skill = locate_existing_skill_across_dirs(&roots, kind, &id, scope) + let skill = locate_existing_skill(root, kind, &id, scope, true) + .or_else(|| locate_existing_skill(vault, kind, &id, scope, false)) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; if skill.enabled == enabled { return Ok(skill); } - let vault = disabled_skill_root(root); - let destination_root = if enabled { root } else { &vault }; + let destination_root = if enabled { root } else { vault }; let source = Path::new(&skill.path); let file_name = source .file_name() @@ -9324,814 +9738,557 @@ fn native_skill_roots(workspace_path: Option<&str>) -> Vec<(AgentType, AgentSkil roots } -fn skill_roots_overlap(first: &Path, second: &Path) -> bool { - first.starts_with(second) || second.starts_with(first) -} - -#[derive(Clone)] -struct SkillPeer { +#[derive(Debug, Clone)] +struct SkillTopologyRoot { agent: AgentType, scope: AgentSkillScope, - kind: SkillStorageKind, - roots: Vec, + active: PathBuf, + resolved_active: PathBuf, + resolved_legacy_vault: PathBuf, } -fn skill_peers(workspace_path: Option<&str>) -> Vec { - let mut peers: Vec = Vec::new(); - for (agent, scope, root) in native_skill_roots(workspace_path) { - if let Some(peer) = peers - .iter_mut() - .find(|p| p.agent == agent && p.scope == scope) - { - peer.roots.push(root); - } else if let Some(spec) = skill_storage_spec(agent) { - peers.push(SkillPeer { +#[derive(Debug, Clone)] +struct SkillRootTopology { + roots: Vec, + incoming_link_origins: HashMap>, + incoming_link_scan_complete: bool, +} + +impl SkillRootTopology { + fn build( + workspace_path: Option<&str>, + data_dir: &Path, + inspect_incoming_links: bool, + ) -> Result { + let mut roots = Vec::new(); + let mut seen = HashSet::new(); + for (agent, scope, active) in native_skill_roots(workspace_path) { + let resolved_active = resolved_skill_root(&active)?; + let identity = (agent, scope, resolved_active.clone()); + if !seen.insert(identity) { + continue; + } + let resolved_legacy_vault = resolved_skill_root(&disabled_skill_root(&active))?; + roots.push(SkillTopologyRoot { agent, scope, - kind: spec.kind, - roots: vec![root], + active, + resolved_active, + resolved_legacy_vault, }); } + let mut topology = Self { + roots, + incoming_link_origins: HashMap::new(), + incoming_link_scan_complete: true, + }; + if inspect_incoming_links { + topology.scan_incoming_links(workspace_path, data_dir)?; + } + Ok(topology) } - peers -} -fn preflight_skill_destination(root: &Path, id: &str) -> Result<(), AcpError> { - // Reserve both layouts, including dangling links and malformed bundles. - for path in [root.join(id), root.join(format!("{id}.md"))] { - match fs::symlink_metadata(&path) { - Ok(_) => { - return Err(AcpError::protocol(format!( - "skill destination collision: '{}' already exists", - path.display() - ))) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return Err(AcpError::protocol(format!( - "failed to inspect skill destination '{}': {e}", - path.display() - ))) - } - } + fn root_is_shared( + &self, + agent: AgentType, + scope: AgentSkillScope, + resolved_root: &Path, + ) -> bool { + self.roots.iter().any(|other| { + (other.agent != agent || other.scope != scope) + && skill_roots_overlap(resolved_root, &other.resolved_active) + }) } - Ok(()) -} -fn skill_root_writable(root: &Path) -> bool { - let mut ancestor = root; - loop { - match fs::metadata(ancestor) { - Ok(metadata) => { - if !metadata.is_dir() || metadata.permissions().readonly() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - let Ok(path) = std::ffi::CString::new(ancestor.as_os_str().as_bytes()) else { - return false; - }; - // access checks search permission and ACLs without creating a probe file. - unsafe { - return libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) == 0; - } - } - #[cfg(not(unix))] - return true; - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let Some(parent) = ancestor.parent() else { - return false; - }; - ancestor = parent; - } - Err(_) => return false, - } + fn path_overlaps_native_root(&self, path: &Path) -> bool { + self.roots + .iter() + .any(|root| skill_roots_overlap(path, &root.resolved_active)) } -} -fn unique_skill_root(peer: &SkillPeer, peers: &[SkillPeer]) -> Result, AcpError> { - for root in &peer.roots { - let resolved = resolved_skill_root(root)?; - if is_read_only_skill_path(peer.agent, root) - || is_read_only_skill_path(peer.agent, &resolved) - || !skill_root_writable(root) - { - continue; - } - let mut unique = true; - for other in peers { - if other.agent == peer.agent && other.scope == peer.scope { + fn path_is_in_legacy_vault(&self, path: &Path) -> bool { + self.roots + .iter() + .any(|root| path.starts_with(&root.resolved_legacy_vault)) + } + + fn scan_incoming_links( + &mut self, + workspace_path: Option<&str>, + data_dir: &Path, + ) -> Result<(), AcpError> { + let mut scan_roots = BTreeMap::new(); + for root in &self.roots { + let Some(spec) = skill_storage_spec(root.agent) else { continue; - } - for other_root in &other.roots { - if skill_roots_overlap(&resolved, &resolved_skill_root(other_root)?) { - unique = false; - } + }; + add_skill_link_scan_root( + &mut scan_roots, + root.resolved_active.clone(), + spec.kind, + ); + add_skill_link_scan_root( + &mut scan_roots, + root.resolved_legacy_vault.clone(), + spec.kind, + ); + let private_vault = private_skill_vault( + root.agent, + root.scope, + workspace_path, + data_dir, + &root.active, + )?; + add_skill_link_scan_root( + &mut scan_roots, + resolved_skill_root(&private_vault)?, + spec.kind, + ); + } + + for (scan_root, kind) in scan_roots { + if let Err(error) = collect_incoming_skill_links( + &scan_root, + kind, + &mut self.incoming_link_origins, + ) { + self.incoming_link_scan_complete = false; + tracing::warn!( + path = %scan_root.display(), + error = %error, + "skill link topology scan was incomplete" + ); } } - if unique { - return Ok(Some(root.clone())); + Ok(()) + } + + fn has_external_incoming_link(&self, source: &Path) -> bool { + if !self.incoming_link_scan_complete { + return true; } + let Ok(sources) = skill_path_identities(source) else { + return true; + }; + sources.iter().any(|source| { + self.incoming_link_origins + .get(source) + .is_some_and(|origins| { + origins + .iter() + .any(|origin| sources.iter().all(|source| !origin.starts_with(source))) + }) + }) } - Ok(None) } -fn shared_skill_affected_peers<'a>( - selected: &SkillPeer, - peers: &'a [SkillPeer], - root: &Path, - layout: AgentSkillLayout, -) -> Result, AcpError> { - let resolved_root = resolved_skill_root(root)?; - let mut affected = Vec::new(); - for peer in peers { - let mut shares = false; - for scan in &peer.roots { - let resolved_scan = resolved_skill_root(scan)?; - if skill_roots_overlap(&resolved_root, &resolved_scan) { - if resolved_root != resolved_scan { - return Err(AcpError::protocol( - "shared skill root has overlapping scan roots that cannot be isolated", - )); - } - shares = true; +fn add_skill_link_scan_root( + roots: &mut BTreeMap, + root: PathBuf, + kind: SkillStorageKind, +) { + roots + .entry(root) + .and_modify(|current| { + if kind == SkillStorageKind::SkillDirectoryOrMarkdownFile { + *current = kind; } - } - if shares - && (peer.agent != selected.agent || peer.scope != selected.scope) - && !(layout == AgentSkillLayout::MarkdownFile - && peer.kind == SkillStorageKind::SkillDirectoryOnly) - { - affected.push(peer); - } - } - Ok(affected) + }) + .or_insert(kind); } -fn preflight_unplanned_incoming_skill_links( - selected: &SkillPeer, - peers: &[SkillPeer], - root: &Path, - skill_id: &str, - layout: AgentSkillLayout, - canonical: &Path, -) -> Result<(), AcpError> { - let resolved_root = resolved_skill_root(root)?; - let expected_name = match layout { - AgentSkillLayout::SkillDirectory => skill_id.to_string(), - AgentSkillLayout::MarkdownFile => format!("{skill_id}.md"), - }; - for peer in peers { - let is_selected = peer.agent == selected.agent && peer.scope == selected.scope; - let shares_canonical_root = - peer.roots.iter().try_fold(false, |shares, peer_root| { - Ok::<_, AcpError>( - shares || resolved_root == resolved_skill_root(peer_root)?, - ) - })?; - for peer_root in &peer.roots { - let vault = disabled_skill_root(peer_root); - for (scan, active) in [(peer_root.as_path(), true), (vault.as_path(), false)] { - let entries = match fs::read_dir(scan) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(AcpError::protocol(format!( - "failed to inspect peer skill root '{}': {error}", - scan.display() - ))) - } - }; - for entry in entries { - let entry = entry - .map_err(|error| { - AcpError::protocol(format!( - "failed to inspect peer skill entry in '{}': {error}", - scan.display() - )) - })? - .path(); - if !skill_link_targets(&entry, canonical) - || skill_entry_layout(&entry, peer.kind).is_none() - { - continue; - } - let planned_restore_link = !is_selected - && shares_canonical_root - && active - && entry.file_name().and_then(|name| name.to_str()) - == Some(expected_name.as_str()); - if planned_restore_link { - continue; - } - return Err(AcpError::protocol(format!( - "skill '{skill_id}' has an incoming peer link '{}' from {}; moving its canonical entry is unsupported", - entry.display(), - peer.agent - ))); - } +fn skill_path_identity(path: &Path) -> Result { + let parent = path + .parent() + .ok_or_else(|| AcpError::protocol("skill path has no parent"))?; + let name = path + .file_name() + .ok_or_else(|| AcpError::protocol("skill path has no filename"))?; + let identity = resolved_skill_root(parent)?.join(name); + #[cfg(windows)] + let identity = PathBuf::from(identity.to_string_lossy().to_lowercase()); + Ok(identity) +} + +fn lexical_skill_path_identity(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| { + AcpError::protocol(format!("failed to resolve current directory: {error}")) + })? + .join(path) + }; + let mut identity = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + identity.pop(); } + _ => identity.push(component.as_os_str()), } } - Ok(()) + #[cfg(windows)] + let identity = PathBuf::from(identity.to_string_lossy().to_lowercase()); + Ok(identity) } -fn shared_skill_restore_links( - affected: &[&SkillPeer], - id: &str, - canonical: &Path, -) -> Result, AcpError> { - let mut links = Vec::new(); - for peer in affected { - reject_multiple_active_skills(&peer.roots, peer.kind, id)?; - let active = locate_existing_skill_across_dirs(&peer.roots, peer.kind, id, peer.scope) - .filter(|item| item.enabled) - .ok_or_else(|| { - AcpError::protocol("shared skill restore would reenable a disabled peer") - })?; - let path = PathBuf::from(active.path); - if !skill_link_targets(&path, canonical) { - return Err(AcpError::protocol( - "shared skill restore would conflict with an independent peer skill", - )); - } - if !path.parent().is_some_and(skill_root_writable) { - return Err(AcpError::protocol( - "shared skill restore link root is not writable", - )); - } - links.push(path); - } - Ok(links) +fn skill_path_identities(path: &Path) -> Result<[PathBuf; 2], AcpError> { + Ok([ + skill_path_identity(path)?, + lexical_skill_path_identity(path)?, + ]) } -fn plan_shared_skill_fanout<'a>( - selected: &SkillPeer, - peers: &'a [SkillPeer], - root: &Path, - skill: &AgentSkillItem, -) -> Result, AcpError> { - preflight_unplanned_incoming_skill_links( - selected, - peers, - root, - &skill.id, - skill.layout, - Path::new(&skill.path), - )?; - let vault = disabled_skill_root(root); - preflight_skill_destination(&vault, &skill.id)?; - for scan in &selected.roots { - preflight_skill_destination(&disabled_skill_root(scan), &skill.id)?; - } - let source = Path::new(&skill.path); - preflight_skill_symlink_move(source, &vault)?; - let filename = source - .file_name() - .ok_or_else(|| AcpError::protocol("skill has no filename"))?; - let mut destinations = Vec::new(); - for peer in shared_skill_affected_peers(selected, peers, root, skill.layout)? { - reject_multiple_active_skills(&peer.roots, peer.kind, &skill.id)?; - for scan in &peer.roots { - preflight_skill_destination(&disabled_skill_root(scan), &skill.id)?; - } - let destination_root = unique_skill_root(peer, peers)?.ok_or_else(|| { - AcpError::protocol(format!( - "shared skill root: {} has no unique writable root", - peer.agent - )) - })?; - preflight_skill_destination(&destination_root, &skill.id)?; - destinations.push((peer, destination_root.join(filename))); +type IncomingSkillLink = Vec<(PathBuf, PathBuf)>; + +fn resolve_peer_skill_link_target(path: &Path, target: PathBuf) -> Result { + if target.is_absolute() { + return Ok(target); } - Ok(destinations) + let parent = path + .parent() + .ok_or_else(|| AcpError::protocol("peer skill link has no parent"))?; + Ok(resolved_skill_root(parent)?.join(target)) } -fn listed_skill_can_toggle( - selected: &SkillPeer, - peers: &[SkillPeer], - skill: &AgentSkillItem, -) -> Result { - reject_multiple_active_skills(&selected.roots, selected.kind, &skill.id)?; - let parent = Path::new(&skill.path).parent(); - let root = selected - .roots - .iter() - .find(|root| { - if skill.enabled { - parent == Some(root.as_path()) - } else { - parent == Some(disabled_skill_root(root).as_path()) +fn incoming_skill_link(path: &Path) -> Result, AcpError> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + AcpError::protocol(format!( + "failed to inspect peer skill link '{}': {error}", + path.display() + )) + })?; + let link_like = metadata.file_type().is_symlink() + || { + #[cfg(windows)] + { + super::experts::path_is_reparse_point(path) } - }) - .ok_or_else(|| AcpError::protocol("listed skill has no owning native root"))?; - if !skill_root_is_shared_with_peers(selected.agent, selected.scope, root, peers)? { - preflight_unplanned_incoming_skill_links( - selected, - peers, - root, - &skill.id, - skill.layout, - Path::new(&skill.path), - )?; - preflight_disabled_skill_root_with_peers(root, peers)?; - return Ok(true); - } - preflight_shared_skill_owner(root, peers)?; - preflight_disabled_skill_root_with_peers(root, peers)?; - if skill.enabled { - if !skill_root_writable(root) || !skill_root_writable(&disabled_skill_root(root)) { - return Ok(false); - } - plan_shared_skill_fanout(selected, peers, root, skill)?; - return Ok(true); - } - if let Some(destination_root) = unique_skill_root(selected, peers)? { - preflight_skill_destination(&destination_root, &skill.id)?; - return Ok(true); - } - if !skill_root_writable(root) || !skill_root_writable(&disabled_skill_root(root)) { - return Ok(false); - } - preflight_skill_destination(root, &skill.id)?; - preflight_skill_symlink_move(Path::new(&skill.path), root)?; - preflight_unplanned_incoming_skill_links( - selected, - peers, - root, - &skill.id, - skill.layout, - Path::new(&skill.path), - )?; - let affected = shared_skill_affected_peers(selected, peers, root, skill.layout)?; - shared_skill_restore_links(&affected, &skill.id, Path::new(&skill.path))?; - Ok(true) -} - -fn preflight_shared_skill_owner(root: &Path, peers: &[SkillPeer]) -> Result<(), AcpError> { - let resolved = resolved_skill_root(root)?; - for peer in peers { - for native in &peer.roots { - let resolved_native = resolved_skill_root(native)?; - if let Ok(relative) = resolved.strip_prefix(&resolved_native) { - // Evaluate the owner's lexical path so aliases cannot erase - // that owner's builtin-directory policy. - if is_read_only_skill_path(peer.agent, &native.join(relative)) { - return Err(AcpError::protocol(format!( - "shared skill root '{}' is read-only for owning agent {}", - root.display(), - peer.agent - ))); + #[cfg(not(windows))] + { + false + } + }; + if !link_like { + return Ok(None); + } + let target = super::experts::read_link_target(path).ok_or_else(|| { + AcpError::protocol(format!( + "failed to read peer skill link '{}'", + path.display() + )) + })?; + let target = resolve_peer_skill_link_target(path, target)?; + let origins = skill_path_identities(path)?; + let mut links = Vec::new(); + let mut seen_pairs = HashSet::new(); + let mut current = target; + for followed_links in 0..=40 { + for target in skill_path_identities(¤t)? { + for origin in &origins { + let pair = (origin.clone(), target.clone()); + if seen_pairs.insert(pair.clone()) { + links.push(pair); } } } - } - Ok(()) -} -fn create_skill_link(source: &Path, destination: &Path) -> std::io::Result<()> { - #[cfg(unix)] - { - super::experts::create_link_raw(source, destination).map(|_| ()) - } - #[cfg(windows)] - { - if source.is_dir() { - // A copy fallback would stop following canonical edits. - junction::create(source, destination) - } else { - std::os::windows::fs::symlink_file(source, destination) + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + AcpError::protocol(format!( + "failed to inspect peer skill link target '{}': {error}", + current.display() + )) + })?; + let link_like = metadata.file_type().is_symlink() + || { + #[cfg(windows)] + { + super::experts::path_is_reparse_point(¤t) + } + #[cfg(not(windows))] + { + false + } + }; + if !link_like { + return Ok(Some(links)); + } + if followed_links == 40 { + return Err(AcpError::protocol(format!( + "too many peer skill link targets from '{}'", + path.display() + ))); } + let next = super::experts::read_link_target(¤t).ok_or_else(|| { + AcpError::protocol(format!( + "failed to read peer skill link target '{}'", + current.display() + )) + })?; + current = resolve_peer_skill_link_target(¤t, next)?; } + unreachable!("peer skill link traversal returns within its bounded loop") } -fn remove_skill_link(path: &Path) -> std::io::Result<()> { - #[cfg(windows)] - if super::experts::path_is_reparse_point(path) && path.is_dir() { - // `junction::delete` strips the reparse data but leaves an empty - // directory behind. `remove_dir` removes the junction entry itself. - return fs::remove_dir(path); +fn index_incoming_skill_link( + links: &mut HashMap>, + origin: PathBuf, + target: PathBuf, +) { + let mut current = Some(target.as_path()); + while let Some(path) = current { + links + .entry(path.to_path_buf()) + .or_default() + .push(origin.clone()); + current = path.parent(); } - fs::remove_file(path) -} - -fn skill_link_targets(path: &Path, canonical: &Path) -> bool { - let Some(target) = super::experts::read_link_target(path) else { - return false; - }; - let target = if target.is_absolute() { - target - } else { - path.parent().unwrap_or_else(|| Path::new("")).join(target) - }; - // Compare the link's direct destination, preserving a canonical entry that - // is itself a symlink. An independent link to the same content is not ours. - let identity = |entry: &Path| -> Option { - Some( - resolved_skill_root(entry.parent()?) - .ok()? - .join(entry.file_name()?), - ) - }; - identity(&target) - .zip(identity(canonical)) - .is_some_and(|(left, right)| left == right) } -fn delete_shared_skill( - canonical: &Path, - peers: &[SkillPeer], - id: &str, - mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>, +fn collect_incoming_skill_links( + root: &Path, + kind: SkillStorageKind, + links: &mut HashMap>, ) -> Result<(), AcpError> { - let mut entries = vec![canonical.to_path_buf()]; - let mut seen = std::collections::HashSet::new(); - for peer in peers { - for root in &peer.roots { - for scan in [root.clone(), disabled_skill_root(root)] { - for path in [scan.join(id), scan.join(format!("{id}.md"))] { - if skill_link_targets(&path, canonical) { - let identity = resolved_skill_root(path.parent().expect("link parent"))? - .join(path.file_name().expect("link filename")); - if seen.insert(identity) { - entries.push(path); - } - } - } + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(AcpError::protocol(format!( + "failed to inspect peer skill root '{}': {error}", + root.display() + ))) + } + }; + for entry in entries { + let entry = entry + .map_err(|error| { + AcpError::protocol(format!( + "failed to inspect peer skill entry in '{}': {error}", + root.display() + )) + })? + .path(); + let Some(layout) = skill_entry_layout(&entry, kind) else { + continue; + }; + if let Some(incoming) = incoming_skill_link(&entry)? { + for (origin, target) in incoming { + index_incoming_skill_link(links, origin, target); } } - } - let staging = canonical - .parent() - .ok_or_else(|| AcpError::protocol("canonical skill has no parent"))? - .join(format!(".codeg-delete-{}", uuid::Uuid::new_v4())); - fs::create_dir(&staging) - .map_err(|e| AcpError::protocol(format!("failed to stage skill deletion: {e}")))?; - let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new(); - for (index, entry) in entries.iter().enumerate() { - let destination = staging.join(index.to_string()); - if let Err(error) = rename(entry, &destination) { - let mut failures = Vec::new(); - for (source, staged) in moved.iter().rev() { - match fs::symlink_metadata(source) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - if let Err(e) = fs::rename(staged, source) { - failures.push(e.to_string()); - } - } - _ => failures.push(format!( - "rollback destination '{}' is occupied or inaccessible", - source.display() - )), + if layout == AgentSkillLayout::SkillDirectory { + let content = skill_content_path(layout, &entry); + if let Some(incoming) = incoming_skill_link(&content)? { + for (origin, target) in incoming { + index_incoming_skill_link(links, origin, target); } } - let _ = fs::remove_dir(&staging); - return Err(AcpError::protocol(format!( - "shared skill deletion failed: {error}; {}", - if failures.is_empty() { - "rolled back".to_string() - } else { - format!( - "rollback failed: {}; recovery directory '{}'", - failures.join("; "), - staging.display() - ) - } - ))); } - moved.push((entry.clone(), destination)); - } - // All visible entries are now gone: deletion is committed. Cleanup failure - // leaves only hidden recovery material, never dangling native scan entries. - for (_, staged) in &moved { - if let Err(error) = remove_skill_entry(staged) { - tracing::warn!(path = %staged.display(), %error, "shared skill deletion committed; recovery cleanup failed"); - } - } - if let Err(error) = fs::remove_dir(&staging) { - tracing::warn!(path = %staging.display(), %error, "shared skill deletion recovery directory retained"); } Ok(()) } -/// Preflight the complete peer plan before changing the canonical entry. -/// The caller holds SKILL_MUTATION_LOCK; link creation is injectable for IO failure tests. -fn set_shared_skill_enabled( - selected: &SkillPeer, - peers: &[SkillPeer], - root: &Path, - skill_id: &str, - enabled: bool, - mut create_link: impl FnMut(&Path, &Path) -> std::io::Result<()>, -) -> Result { - let id = validate_skill_id(skill_id)?; - let original = - locate_existing_skill_across_dirs(&selected.roots, selected.kind, &id, selected.scope) - .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; - reject_multiple_active_skills(&selected.roots, selected.kind, &id)?; - if original.enabled == enabled { - return Ok(original); - } - preflight_shared_skill_owner(root, peers)?; - let resolved_root = resolved_skill_root(root)?; - let vault = disabled_skill_root(root); - let resolved_vault = resolved_skill_root(&vault)?; - preflight_disabled_skill_root_with_peers(root, peers)?; - let first_disable = original.enabled - && resolved_skill_root(Path::new(&original.path).parent().expect("skill parent"))? - == resolved_root; - let canonical_item = if first_disable { - original.clone() - } else { - locate_existing_skill(&vault, selected.kind, &id, selected.scope, false) - .ok_or_else(|| AcpError::protocol("shared canonical skill not found"))? - }; - let file_name = Path::new(&canonical_item.path) - .file_name() - .expect("skill filename"); - let canonical = resolved_vault.join(file_name); - let mut destinations = Vec::new(); - let mut affected = Vec::new(); - let mut restore_shared = false; - let mut redundant_links = Vec::new(); - if first_disable { - for (peer, destination) in plan_shared_skill_fanout(selected, peers, root, &original)? { - destinations.push(destination); - affected.push(peer); - } - } else if enabled { - match unique_skill_root(selected, peers)? { - Some(destination_root) => { - preflight_skill_destination(&destination_root, &id)?; - destinations.push(destination_root.join(file_name)); - } - None => { - preflight_skill_destination(root, &id)?; - preflight_skill_symlink_move(&canonical, root)?; - preflight_unplanned_incoming_skill_links( - selected, - peers, - root, - &id, - canonical_item.layout, - &canonical, - )?; - affected = - shared_skill_affected_peers(selected, peers, root, canonical_item.layout)?; - redundant_links = shared_skill_restore_links(&affected, &id, &canonical)?; - restore_shared = true; - } - } - } else if !skill_link_targets(Path::new(&original.path), &canonical) { - return Err(AcpError::protocol( - "active skill is not a managed canonical link", - )); - } +#[derive(Debug, Clone)] +struct SkillRootPlan { + active: PathBuf, + resolved_active: PathBuf, + vault: PathBuf, + resolved_vault: PathBuf, + legacy_vault: PathBuf, + shared: bool, + vault_conflict: bool, +} - let mut moved = false; - let mut created: Vec = Vec::new(); - let mut removed = false; - let mut removed_redundant = Vec::new(); - let restored_path = root.join(file_name); - let result = (|| { - if first_disable { - fs::create_dir_all(&vault)?; - fs::rename(&original.path, &canonical)?; - moved = true; - } - if restore_shared { - for link in &redundant_links { - remove_skill_link(link)?; - removed_redundant.push(link.clone()); - } - fs::rename(&canonical, &restored_path)?; - moved = true; - } - for destination in &destinations { - fs::create_dir_all(destination.parent().expect("destination parent"))?; - let linked = create_link(&canonical, destination); - if linked.is_ok() || skill_link_targets(destination, &canonical) { - created.push(destination.clone()); - } - linked?; - } - if !first_disable && !enabled { - remove_skill_link(Path::new(&original.path))?; - removed = true; - } - let item = list_skills_from_roots(selected.scope, &selected.roots, selected.kind) - .map_err(|e| std::io::Error::other(e.to_string()))? - .into_iter() - .find(|item| item.id == id && item.enabled == enabled) - .ok_or_else(|| std::io::Error::other("requested skill state was not reached"))?; - for peer in affected { - if !list_skills_from_roots(peer.scope, &peer.roots, peer.kind) - .map_err(|e| std::io::Error::other(e.to_string()))? - .iter() - .any(|item| item.id == id && item.enabled) - { - return Err(std::io::Error::other("peer skill state was not preserved")); +fn hash_skill_identity(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn normalized_skill_identity(path: &Path) -> Result { + let value = resolved_skill_root(path)?.to_string_lossy().into_owned(); + #[cfg(windows)] + let value = value.to_lowercase(); + Ok(value) +} + +fn agent_vault_component(agent: AgentType) -> String { + let wire = agent.as_wire(); + let readable = wire + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' } + }) + .collect::(); + format!("{}-{}", readable, &hash_skill_identity(wire.as_ref())[..12]) +} + +fn private_skill_vault( + agent: AgentType, + scope: AgentSkillScope, + workspace_path: Option<&str>, + data_dir: &Path, + active_root: &Path, +) -> Result { + let root_key = hash_skill_identity(&normalized_skill_identity(active_root)?); + let agent = agent_vault_component(agent); + match scope { + AgentSkillScope::Global => { + let parent = active_root + .parent() + .ok_or_else(|| AcpError::protocol("global skill root has no parent"))?; + Ok(parent + .join(".codeg-skill-vaults") + .join("v1") + .join(agent) + .join(root_key)) } - Ok(item) - })(); - match result { - Ok(mut item) => { - apply_skill_capabilities(selected.agent, &mut item); - Ok(item) - } - Err(error) => { - let mut failures = Vec::new(); - for destination in created.iter().rev() { - if !skill_link_targets(destination, &canonical) { - failures.push(format!( - "rollback refused for changed link '{}'", - destination.display() - )); - } else if let Err(e) = remove_skill_link(destination) { - failures.push(e.to_string()); - } - } - if moved { - let (from, to) = if restore_shared { - (restored_path.as_path(), canonical.as_path()) - } else { - (canonical.as_path(), Path::new(&original.path)) - }; - if fs::symlink_metadata(to).is_ok() { - failures.push("rollback source is occupied".to_string()); - } else if let Err(e) = fs::rename(from, to) { - failures.push(e.to_string()); - } - } - for link in removed_redundant { - if let Err(e) = create_skill_link(&canonical, &link) { - failures.push(e.to_string()); - } - } - if removed { - if let Err(e) = create_skill_link(&canonical, Path::new(&original.path)) { - failures.push(e.to_string()); - } - } - Err(AcpError::protocol(format!( - "shared skill toggle failed: {error}; {}", - if failures.is_empty() { - "rolled back".to_string() - } else { - format!("rollback failed: {}", failures.join("; ")) - } - ))) + AgentSkillScope::Project => { + let workspace = workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| { + AcpError::protocol("workspace_path is required for project scoped skills") + })?; + let workspace_key = + hash_skill_identity(&normalized_skill_identity(Path::new(workspace))?); + Ok(data_dir + .join("skill-vaults") + .join("v1") + .join(workspace_key) + .join(agent) + .join(root_key)) } } } -fn skill_root_is_shared( - agent_type: AgentType, +fn skill_root_plans( + agent: AgentType, scope: AgentSkillScope, workspace_path: Option<&str>, - root: &Path, -) -> Result { - skill_root_is_shared_with_peers(agent_type, scope, root, &skill_peers(workspace_path)) + data_dir: &Path, + topology: &SkillRootTopology, +) -> Result, AcpError> { + scoped_skill_dirs(agent, scope, workspace_path)? + .into_iter() + .map(|active| { + let resolved_active = resolved_skill_root(&active)?; + let legacy_vault = disabled_skill_root(&active); + let vault = private_skill_vault(agent, scope, workspace_path, data_dir, &active)?; + let resolved_vault = resolved_skill_root(&vault)?; + let project_vault_in_workspace = if scope == AgentSkillScope::Project { + let workspace = workspace_path.expect("project scope validated above"); + let resolved_workspace = resolved_skill_root(Path::new(workspace))?; + skill_roots_overlap(&resolved_vault, &resolved_workspace) + } else { + false + }; + Ok(SkillRootPlan { + active, + resolved_active: resolved_active.clone(), + vault, + resolved_vault: resolved_vault.clone(), + legacy_vault, + shared: topology.root_is_shared(agent, scope, &resolved_active), + vault_conflict: project_vault_in_workspace + || topology.path_overlaps_native_root(&resolved_vault), + }) + }) + .collect() } -fn skill_root_is_shared_with_peers( - agent_type: AgentType, - scope: AgentSkillScope, - root: &Path, - peers: &[SkillPeer], -) -> Result { - let resolved_root = resolved_skill_root(root)?; - for peer in peers { - if peer.agent != agent_type || peer.scope != scope { - for peer_root in &peer.roots { - if skill_roots_overlap(&resolved_root, &resolved_skill_root(peer_root)?) { - return Ok(true); - } - } - } - } - Ok(false) +fn skill_roots_overlap(first: &Path, second: &Path) -> bool { + first.starts_with(second) || second.starts_with(first) } -fn preflight_disabled_skill_root( - root: &Path, - workspace_path: Option<&str>, -) -> Result<(), AcpError> { - preflight_disabled_skill_root_with_peers(root, &skill_peers(workspace_path)) -} +fn delete_codex_skill_entry(skill_path: &Path, content_path: &Path) -> Result<(), AcpError> { + let _config_guard = lock_codex_config_mutation()?; + let codex_home = codex_home_dir(); + let base = read_codex_config_or_empty()?; + let content_paths = [content_path.to_path_buf()]; + let updated = remove_codex_disabled_skill_path_rules(&base, &codex_home, &content_paths)?; + if let Some(next) = updated.as_deref() { + persist_codex_native_config_files_unlocked(None, Some(next))?; + } -fn preflight_disabled_skill_root_with_peers( - root: &Path, - peers: &[SkillPeer], -) -> Result<(), AcpError> { - let vault = disabled_skill_root(root); - let resolved_vault = resolved_skill_root(&vault)?; - let resolved_root = resolved_skill_root(root)?; - for native_root in peers.iter().flat_map(|peer| &peer.roots) { - let resolved_native = resolved_skill_root(native_root)?; - if skill_roots_overlap(&resolved_vault, &resolved_native) { - return Err(AcpError::protocol(format!( - "disabled skill vault '{}' overlaps native scan root '{}'", - vault.display(), - native_root.display() - ))); - } - if resolved_native != resolved_root - && resolved_vault == resolved_skill_root(&disabled_skill_root(native_root))? - { - return Err(AcpError::protocol(format!( - "shared skill storage: disabled vault '{}' is shared by native roots '{}' and '{}'; toggling is unsupported", - vault.display(), root.display(), native_root.display() - ))); + if let Err(delete_error) = remove_skill_entry(skill_path) { + if updated.is_some() { + if let Err(rollback_error) = + persist_codex_native_config_files_unlocked(None, Some(&base)) + { + return Err(AcpError::protocol(format!( + "failed to delete skill entry: {delete_error}; Codex config rollback failed: {rollback_error}" + ))); + } } + return Err(AcpError::protocol(format!( + "failed to delete skill entry: {delete_error}" + ))); } Ok(()) } -fn reject_multiple_active_skills( - roots: &[PathBuf], - kind: SkillStorageKind, - skill_id: &str, -) -> Result<(), AcpError> { - let mut matches = 0; - let mut seen_roots = std::collections::HashSet::new(); - for root in roots { - if !seen_roots.insert(resolved_skill_root(root)?) { - continue; - } - let entries = match fs::read_dir(root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { +fn preflight_skill_destination(root: &Path, id: &str) -> Result<(), AcpError> { + // Reserve both layouts, including dangling links and malformed bundles. + for path in [root.join(id), root.join(format!("{id}.md"))] { + match fs::symlink_metadata(&path) { + Ok(_) => { return Err(AcpError::protocol(format!( - "failed to inspect active skills directory '{}': {error}", - root.display() - ))); + "skill destination collision: '{}' already exists", + path.display() + ))) } - }; - // Listing intentionally deduplicates IDs; preflight must count both - // layouts so disabling a bundle cannot reveal its same-ID flat file. - for entry in entries { - let path = entry - .map_err(|e| AcpError::protocol(format!("failed to inspect active skill: {e}")))? - .path(); - let directory_match = path.file_name().and_then(|name| name.to_str()) == Some(skill_id) - && path.is_dir() - && path.join("SKILL.md").is_file(); - let markdown_match = matches!(kind, SkillStorageKind::SkillDirectoryOrMarkdownFile) - && path.file_stem().and_then(|name| name.to_str()) == Some(skill_id) - && is_markdown_file(&path) - && path.is_file(); - if directory_match || markdown_match { - matches += 1; - if matches > 1 { - return Err(AcpError::protocol(format!( - "multiple active skills share id '{skill_id}'; resolve duplicates before toggling" - ))); - } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(AcpError::protocol(format!( + "failed to inspect skill destination '{}': {e}", + path.display() + ))) } } } Ok(()) } -fn finish_private_skill_toggle( - agent_type: AgentType, - dirs: &[PathBuf], - kind: SkillStorageKind, - original: &AgentSkillItem, - moved: AgentSkillItem, - enabled: bool, -) -> Result { - let authoritative = list_skills_from_roots(original.scope, dirs, kind) - .map(|items| items.into_iter().find(|item| item.id == original.id)); - if let Ok(Some(mut item)) = authoritative { - if item.enabled == enabled { - apply_skill_capabilities(agent_type, &mut item); - return Ok(item); - } - } - if moved.path != original.path { - match fs::symlink_metadata(&original.path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - _ => return Err(AcpError::protocol(format!( - "requested skill state was not reached; rollback refused because original path '{}' is occupied or inaccessible; moved entry remains at '{}'", - original.path, moved.path - ))), +fn skill_root_writable(root: &Path) -> bool { + let mut ancestor = root; + loop { + match fs::metadata(ancestor) { + Ok(metadata) => { + if !metadata.is_dir() || metadata.permissions().readonly() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let Ok(path) = std::ffi::CString::new(ancestor.as_os_str().as_bytes()) else { + return false; + }; + // access checks search permission and ACLs without creating a probe file. + unsafe { + return libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) == 0; + } + } + #[cfg(not(unix))] + return true; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let Some(parent) = ancestor.parent() else { + return false; + }; + ancestor = parent; + } + Err(_) => return false, } - fs::rename(&moved.path, &original.path).map_err(|error| { - AcpError::protocol(format!( - "requested skill state was not reached; rollback from '{}' to '{}' failed: {error}", - moved.path, original.path - )) - })?; } - Err(AcpError::protocol( - "requested skill state was not reached; skill move rolled back", - )) } #[derive(Debug, Clone, Default, Deserialize)] @@ -14106,10 +14263,208 @@ pub async fn acp_reorder_agents( acp_reorder_agents_core(&agent_types, &db, &emitter).await } -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn acp_list_agent_skills( +fn load_codex_skill_config() -> Result { + let codex_home = codex_home_dir(); + read_codex_config_or_empty().and_then(|raw| parse_codex_skill_config(&raw, &codex_home)) +} + +#[derive(Debug, Deserialize)] +struct CodexPluginManifest { + name: String, + #[serde(default)] + version: String, + skills: Option, +} + +fn discover_codex_plugin_skills( + codex_home: &Path, + config: &CodexSkillConfig, +) -> Result, AcpError> { + let mut by_id = BTreeMap::new(); + for plugin in &config.enabled_plugins { + let cache_root = codex_home + .join("plugins") + .join("cache") + .join(&plugin.marketplace) + .join(&plugin.name); + let versions = match fs::read_dir(&cache_root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + tracing::warn!(path = %cache_root.display(), %error, "failed to inspect enabled Codex plugin cache"); + continue; + } + }; + let mut candidates = Vec::new(); + for version in versions.flatten() { + let plugin_root = version.path(); + let manifest_path = plugin_root.join(".codex-plugin").join("plugin.json"); + let Ok(raw) = fs::read_to_string(&manifest_path) else { + continue; + }; + let Ok(manifest) = serde_json::from_str::(&raw) else { + tracing::warn!(path = %manifest_path.display(), "ignored invalid Codex plugin manifest"); + continue; + }; + if manifest.name != plugin.name { + continue; + } + let Some(skills) = manifest + .skills + .as_deref() + .map(str::trim) + .filter(|skills| !skills.is_empty()) + else { + continue; + }; + let relative = Path::new(skills); + if relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + tracing::warn!(path = %manifest_path.display(), "ignored unsafe Codex plugin skills path"); + continue; + } + let skills_root = plugin_root.join(relative); + let Some((resolved_plugin, resolved_skills)) = fs::canonicalize(&plugin_root) + .ok() + .zip(fs::canonicalize(&skills_root).ok()) + else { + continue; + }; + if !resolved_skills.starts_with(&resolved_plugin) { + tracing::warn!(path = %manifest_path.display(), "ignored Codex plugin skills path outside plugin root"); + continue; + } + let parsed_version = semver::Version::parse(&manifest.version).ok(); + candidates.push(( + parsed_version, + version.file_name().to_string_lossy().into_owned(), + resolved_skills, + )); + } + let Some((_, _, skills_root)) = candidates + .into_iter() + .max_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))) + else { + continue; + }; + for mut skill in list_skills_from_dir( + AgentSkillScope::Global, + &skills_root, + SkillStorageKind::SkillDirectoryOrMarkdownFile, + )? { + let local_name = skill.name.clone(); + let namespaced = format!("{}:{local_name}", plugin.name); + skill.id = namespaced.clone(); + skill.name = namespaced.clone(); + skill.read_only = true; + skill.enabled = + config.skill_enabled(&absolute_skill_content_path(&skill)?, &namespaced); + skill.can_toggle = true; + skill.toggle_reason = None; + by_id.entry(namespaced).or_insert(skill); + } + } + Ok(by_id.into_values().collect()) +} + +fn validate_skill_lookup_id(agent_type: AgentType, raw: &str) -> Result { + if let Ok(id) = validate_skill_id(raw) { + return Ok(id); + } + if agent_type == AgentType::Codex { + if let Some((plugin, skill)) = raw.trim().split_once(':') { + let plugin = validate_skill_id(plugin)?; + let skill = validate_skill_id(skill)?; + return Ok(format!("{plugin}:{skill}")); + } + } + validate_skill_id(raw) +} + +fn codex_plugin_skill_by_id( + id: &str, + config: &CodexSkillConfig, +) -> Result, AcpError> { + Ok(discover_codex_plugin_skills(&codex_home_dir(), config)? + .into_iter() + .find(|skill| skill.id == id)) +} + +fn finalize_planned_skill( + agent_type: AgentType, + planned: &PlannedSkill, + plans: &[SkillRootPlan], + inventory: &SkillInventory, + topology: &SkillRootTopology, + codex_config: Option<&Result>, +) -> Result { + let mut skill = planned.item.clone(); + apply_skill_capabilities(agent_type, &mut skill); + + if agent_type == AgentType::Codex { + let Some(active) = inventory.active_by_id.get(&skill.id) else { + set_skill_unavailable(&mut skill, AgentSkillToggleReason::LegacyState); + return Ok(skill); + }; + match codex_config { + Some(Ok(config)) => { + if is_read_only_skill_path(AgentType::Codex, Path::new(&skill.path)) + && !config.bundled_enabled + { + skill.enabled = false; + set_skill_unavailable(&mut skill, AgentSkillToggleReason::BundledDisabled); + } else { + skill.enabled = codex_skill_entries_enabled(active, config)?; + skill.can_toggle = true; + skill.toggle_reason = None; + } + } + _ => set_skill_unavailable(&mut skill, AgentSkillToggleReason::ConfigError), + } + return Ok(skill); + } + + if is_codeg_managed_skill(&skill) { + set_skill_unavailable(&mut skill, AgentSkillToggleReason::ManagedElsewhere); + return Ok(skill); + } + if planned.residence == SkillResidence::LegacyVault + || skill_resolves_to_legacy_vault(&skill, topology) + { + set_skill_unavailable(&mut skill, AgentSkillToggleReason::LegacyState); + return Ok(skill); + } + + let plan = plans + .get(planned.plan_index) + .ok_or_else(|| AcpError::protocol("skill has no owning root plan"))?; + if let Some(reason) = classify_non_codex_skill(planned, plan, inventory, topology) { + set_skill_unavailable(&mut skill, reason); + } else { + skill.can_toggle = true; + skill.toggle_reason = None; + } + Ok(skill) +} + +fn skill_scopes(workspace_path: Option<&str>) -> Vec { + let mut scopes = vec![AgentSkillScope::Global]; + if workspace_path + .map(str::trim) + .is_some_and(|workspace| !workspace.is_empty()) + { + scopes.push(AgentSkillScope::Project); + } + scopes +} + +pub(crate) async fn acp_list_agent_skills_core( agent_type: AgentType, workspace_path: Option, + data_dir: &Path, ) -> Result { let Some(spec) = skill_storage_spec(agent_type) else { return Ok(AgentSkillsListResult { @@ -14122,85 +14477,58 @@ pub async fn acp_list_agent_skills( let mut locations = Vec::new(); let mut skills_by_key: BTreeMap = BTreeMap::new(); - - for dir in &spec.global_dirs { - locations.push(AgentSkillLocation { - scope: AgentSkillScope::Global, - path: dir.to_string_lossy().to_string(), - exists: dir.exists(), - }); - } - for skill in list_skills_from_roots( - AgentSkillScope::Global, - &spec.global_dirs, - spec.kind, - )? { - let key = format!("global:{}", skill.id); - skills_by_key.entry(key).or_insert(skill); - } - - if let Some(workspace) = workspace_path.as_deref().map(str::trim) { - if !workspace.is_empty() { - // Same base the WRITE path resolves through `scoped_skill_dirs` — - // for DeepSeek that is the repo root, not the workspace. Joining - // onto the workspace here instead would make a skill saved from a - // nested workspace vanish from the list that is meant to show it. - let base = project_skill_base(agent_type, workspace); - let project_dirs = spec - .project_rel_dirs - .iter() - .map(|relative| base.join(relative)) - .collect::>(); - for project_dir in &project_dirs { - locations.push(AgentSkillLocation { - scope: AgentSkillScope::Project, - path: project_dir.to_string_lossy().to_string(), - exists: project_dir.exists(), - }); - } - for skill in - list_skills_from_roots(AgentSkillScope::Project, &project_dirs, spec.kind)? - { - let key = format!("project:{}", skill.id); - skills_by_key.entry(key).or_insert(skill); - } - } - } - - let mut skills = skills_by_key.into_values().collect::>(); - let peers = skill_peers(workspace_path.as_deref()); + let topology = SkillRootTopology::build( + workspace_path.as_deref(), + data_dir, + agent_type != AgentType::Codex, + )?; let codex_skill_config = if agent_type == AgentType::Codex { - let codex_home = codex_home_dir(); - Some( - read_codex_config_or_empty() - .and_then(|raw| parse_codex_skill_config(&raw, &codex_home)), - ) + Some(load_codex_skill_config()) } else { None }; - for skill in &mut skills { - apply_skill_capabilities(agent_type, skill); - if agent_type == AgentType::Codex && skill.enabled { - let active = scoped_skill_dirs(agent_type, skill.scope, workspace_path.as_deref()) - .and_then(|roots| active_skill_entries(&roots, spec.kind, &skill.id, skill.scope)); - match (active, codex_skill_config.as_ref()) { - (Ok(active), Some(Ok(config))) if !active.is_empty() => { - skill.enabled = codex_skill_entries_enabled(&active, config)?; - skill.can_toggle = true; - } - _ => skill.can_toggle = false, - } - continue; + + for scope in skill_scopes(workspace_path.as_deref()) { + let plans = skill_root_plans( + agent_type, + scope, + workspace_path.as_deref(), + data_dir, + &topology, + )?; + for plan in &plans { + locations.push(AgentSkillLocation { + scope, + path: plan.active.to_string_lossy().into_owned(), + exists: plan.active.exists(), + }); } - if skill.can_toggle { - skill.can_toggle = peers - .iter() - .find(|peer| peer.agent == agent_type && peer.scope == skill.scope) - .is_some_and(|selected| { - listed_skill_can_toggle(selected, &peers, skill).unwrap_or(false) - }); + let inventory = list_skills_from_plans(scope, &plans, spec.kind)?; + for planned in &inventory.listed { + let skill = finalize_planned_skill( + agent_type, + planned, + &plans, + &inventory, + &topology, + codex_skill_config.as_ref(), + )?; + let key = format!("{}:{}", scope_rank(scope), skill.id); + skills_by_key.entry(key).or_insert(skill); + } + } + + // Plugin skills live in Codex's cache rather than one of the mutable + // skill roots above. Only an enabled plugin declaration makes its cache + // visible to Codex, so do not surface cache contents on config failures. + if let Some(Ok(config)) = codex_skill_config.as_ref() { + for skill in discover_codex_plugin_skills(&codex_home_dir(), config)? { + let key = format!("{}:{}", scope_rank(skill.scope), skill.id); + skills_by_key.entry(key).or_insert(skill); } } + + let mut skills = skills_by_key.into_values().collect::>(); skills.sort_by(|a, b| { scope_rank(a.scope) .cmp(&scope_rank(b.scope)) @@ -14215,13 +14543,13 @@ pub async fn acp_list_agent_skills( }) } -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn acp_set_agent_skill_enabled( +pub(crate) async fn acp_set_agent_skill_enabled_core( agent_type: AgentType, scope: AgentSkillScope, skill_id: String, workspace_path: Option, enabled: bool, + data_dir: &Path, ) -> Result { let _guard = SKILL_MUTATION_LOCK .lock() @@ -14231,116 +14559,162 @@ pub async fn acp_set_agent_skill_enabled( "{agent_type} skills are not supported in Settings yet" )) })?; - let id = validate_skill_id(&skill_id)?; - let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; - let mut skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) - .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; - apply_skill_capabilities(agent_type, &mut skill); - if agent_type == AgentType::Codex && skill.enabled { - let active = active_skill_entries(&dirs, spec.kind, &id, scope)?; - return set_codex_skill_enabled_native(skill, &active, enabled); - } - let parent = Path::new(&skill.path).parent(); - let root = dirs - .iter() - .find(|root| { - if skill.enabled { - parent == Some(root.as_path()) - } else { - parent == Some(disabled_skill_root(root).as_path()) - } - }) - .ok_or_else(|| AcpError::protocol("skill has no owning native root"))?; - if !skill.can_toggle || is_read_only_skill_path(agent_type, root) { - return Err(AcpError::protocol(format!( - "skill '{id}' is a built-in system skill and cannot be toggled" - ))); - } - reject_multiple_active_skills(&dirs, spec.kind, &id)?; - let peers = skill_peers(workspace_path.as_deref()); - let selected = SkillPeer { - agent: agent_type, - scope, - kind: spec.kind, - roots: dirs.clone(), - }; - for candidate in &dirs { - if !skill_root_is_shared_with_peers(agent_type, scope, candidate, &peers)? { - continue; - } - let canonical = locate_existing_skill( - &disabled_skill_root(candidate), - spec.kind, - &id, - scope, - false, - ); - if candidate == root - || canonical.is_some_and(|item| { - skill_link_targets(Path::new(&skill.path), Path::new(&item.path)) - }) - { - return set_shared_skill_enabled( - &selected, - &peers, - candidate, - &id, + let id = validate_skill_lookup_id(agent_type, &skill_id)?; + if agent_type == AgentType::Codex { + let config = load_codex_skill_config()?; + if let Some(plugin_skill) = codex_plugin_skill_by_id(&id, &config)? { + return set_codex_skill_enabled_native( + plugin_skill.clone(), + std::slice::from_ref(&plugin_skill), enabled, - create_skill_link, ); } } - if skill.enabled != enabled { - preflight_unplanned_incoming_skill_links( - &selected, - &peers, - root, + let topology = SkillRootTopology::build( + workspace_path.as_deref(), + data_dir, + agent_type != AgentType::Codex, + )?; + let plans = skill_root_plans( + agent_type, + scope, + workspace_path.as_deref(), + data_dir, + &topology, + )?; + let inventory = list_skills_from_plans(scope, &plans, spec.kind)?; + let planned = locate_skill_in_inventory(&inventory, &id) + .cloned() + .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; + let codex_config = (agent_type == AgentType::Codex).then(load_codex_skill_config); + let listed = finalize_planned_skill( + agent_type, + &planned, + &plans, + &inventory, + &topology, + codex_config.as_ref(), + )?; + if !listed.can_toggle { + return Err(toggle_reason_error( &id, - skill.layout, - Path::new(&skill.path), - )?; - preflight_disabled_skill_root(root, workspace_path.as_deref())?; + listed + .toggle_reason + .unwrap_or(AgentSkillToggleReason::StorageConflict), + )); } - let moved = set_private_skill_enabled(root, spec.kind, scope, &id, enabled)?; - finish_private_skill_toggle(agent_type, &dirs, spec.kind, &skill, moved, enabled) -} - -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn acp_read_agent_skill( - agent_type: AgentType, - scope: AgentSkillScope, - skill_id: String, - workspace_path: Option, -) -> Result { - let _guard = SKILL_MUTATION_LOCK - .lock() - .map_err(|_| AcpError::protocol("skill mutation lock poisoned"))?; - let Some(spec) = skill_storage_spec(agent_type) else { - return Err(AcpError::protocol(format!( - "{agent_type} skills are not supported in Settings yet" - ))); - }; - let id = validate_skill_id(&skill_id)?; - let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; + if agent_type == AgentType::Codex { + let active = inventory + .active_by_id + .get(&id) + .ok_or_else(|| AcpError::protocol("active Codex skill entry not found"))?; + return set_codex_skill_enabled_native(listed, active, enabled); + } + + let plan = plans + .get(planned.plan_index) + .ok_or_else(|| AcpError::protocol("skill has no owning root plan"))?; + let original = planned.item; + let moved = + set_private_skill_enabled_at(&plan.active, &plan.vault, spec.kind, scope, &id, enabled)?; + let refreshed = list_skills_from_plans(scope, &plans, spec.kind)?; + if let Some(authoritative) = locate_skill_in_inventory(&refreshed, &id) { + if authoritative.item.enabled == enabled { + return finalize_planned_skill( + agent_type, + authoritative, + &plans, + &refreshed, + &topology, + None, + ); + } + } + if moved.path != original.path { + match fs::symlink_metadata(&original.path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => { + return Err(AcpError::protocol(format!( + "requested skill state was not reached; rollback refused because original path '{}' is occupied or inaccessible; moved entry remains at '{}'", + original.path, moved.path + ))) + } + } + fs::rename(&moved.path, &original.path).map_err(|error| { + AcpError::protocol(format!( + "requested skill state was not reached; rollback from '{}' to '{}' failed: {error}", + moved.path, original.path + )) + })?; + } + Err(AcpError::protocol( + "requested skill state was not reached; skill move rolled back", + )) +} - let mut skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) +pub(crate) async fn acp_read_agent_skill_core( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + data_dir: &Path, +) -> Result { + let _guard = SKILL_MUTATION_LOCK + .lock() + .map_err(|_| AcpError::protocol("skill mutation lock poisoned"))?; + let Some(spec) = skill_storage_spec(agent_type) else { + return Err(AcpError::protocol(format!( + "{agent_type} skills are not supported in Settings yet" + ))); + }; + let id = validate_skill_lookup_id(agent_type, &skill_id)?; + if agent_type == AgentType::Codex { + let config = load_codex_skill_config()?; + if let Some(skill) = codex_plugin_skill_by_id(&id, &config)? { + let content_path = skill_content_path(skill.layout, Path::new(&skill.path)); + let content = fs::read_to_string(&content_path) + .map_err(|e| AcpError::protocol(format!("failed to read skill content: {e}")))?; + return Ok(AgentSkillContent { skill, content }); + } + } + let topology = SkillRootTopology::build( + workspace_path.as_deref(), + data_dir, + agent_type != AgentType::Codex, + )?; + let plans = skill_root_plans( + agent_type, + scope, + workspace_path.as_deref(), + data_dir, + &topology, + )?; + let inventory = list_skills_from_plans(scope, &plans, spec.kind)?; + let planned = locate_skill_in_inventory(&inventory, &id) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; - apply_skill_capabilities(agent_type, &mut skill); - apply_codex_native_skill_state(agent_type, &dirs, spec.kind, &mut skill); + let codex_config = (agent_type == AgentType::Codex).then(load_codex_skill_config); + let skill = finalize_planned_skill( + agent_type, + planned, + &plans, + &inventory, + &topology, + codex_config.as_ref(), + )?; let content_path = skill_content_path(skill.layout, Path::new(&skill.path)); let content = fs::read_to_string(&content_path) .map_err(|e| AcpError::protocol(format!("failed to read skill content: {e}")))?; Ok(AgentSkillContent { skill, content }) } -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn acp_save_agent_skill( +pub(crate) async fn acp_save_agent_skill_core( agent_type: AgentType, scope: AgentSkillScope, skill_id: String, content: String, workspace_path: Option, layout: Option, + data_dir: &Path, ) -> Result { let _guard = SKILL_MUTATION_LOCK .lock() @@ -14350,21 +14724,50 @@ pub async fn acp_save_agent_skill( "{agent_type} skills are not supported in Settings yet" ))); }; + let lookup_id = validate_skill_lookup_id(agent_type, &skill_id)?; + if agent_type == AgentType::Codex { + let config = load_codex_skill_config()?; + if codex_plugin_skill_by_id(&lookup_id, &config)?.is_some() { + return Err(AcpError::protocol(format!( + "skill '{lookup_id}' is managed or read-only and cannot be modified here" + ))); + } + } + // A colon is valid only for a plugin skill that the enabled-plugin lookup + // resolved above. New and ordinary user skills keep the native ID rules. let id = validate_skill_id(&skill_id)?; - let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; - let preferred_dir = preferred_scope_skill_dir(agent_type, scope, workspace_path.as_deref())?; - - let existing = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope); - if let Some(ref item) = existing { - if is_read_only_skill_path(agent_type, Path::new(&item.path)) { + let topology = SkillRootTopology::build( + workspace_path.as_deref(), + data_dir, + agent_type != AgentType::Codex, + )?; + let plans = skill_root_plans( + agent_type, + scope, + workspace_path.as_deref(), + data_dir, + &topology, + )?; + let inventory = list_skills_from_plans(scope, &plans, spec.kind)?; + let existing = locate_skill_in_inventory(&inventory, &id).cloned(); + if let Some(ref planned) = existing { + if planned.residence == SkillResidence::LegacyVault + || skill_resolves_to_legacy_vault(&planned.item, &topology) + || is_codeg_managed_skill(&planned.item) + || is_read_only_skill_path(agent_type, Path::new(&planned.item.path)) + { return Err(AcpError::protocol(format!( - "skill '{id}' is a built-in system skill and cannot be modified" + "skill '{id}' is managed or read-only and cannot be modified here" ))); } } - let mut skill = if let Some(item) = existing { - item + let mut skill = if let Some(planned) = existing { + planned.item } else { + let preferred_dir = plans + .first() + .map(|plan| plan.active.as_path()) + .ok_or_else(|| AcpError::protocol("no skill directory resolved for this agent"))?; let new_layout = match spec.kind { SkillStorageKind::SkillDirectoryOnly => AgentSkillLayout::SkillDirectory, SkillStorageKind::SkillDirectoryOrMarkdownFile => { @@ -14398,17 +14801,28 @@ pub async fn acp_save_agent_skill( .map_err(|e| AcpError::protocol(format!("failed to write skill content: {e}")))?; skill.description = read_skill_description(&content_path); - apply_codex_native_skill_state(agent_type, &dirs, spec.kind, &mut skill); + skill.name = read_skill_frontmatter_name(&content_path).unwrap_or_else(|| id.clone()); - Ok(skill) + let refreshed = list_skills_from_plans(scope, &plans, spec.kind)?; + let planned = locate_skill_in_inventory(&refreshed, &id) + .ok_or_else(|| AcpError::protocol("saved skill could not be read back"))?; + let codex_config = (agent_type == AgentType::Codex).then(load_codex_skill_config); + finalize_planned_skill( + agent_type, + planned, + &plans, + &refreshed, + &topology, + codex_config.as_ref(), + ) } -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn acp_delete_agent_skill( +pub(crate) async fn acp_delete_agent_skill_core( agent_type: AgentType, scope: AgentSkillScope, skill_id: String, workspace_path: Option, + data_dir: &Path, ) -> Result<(), AcpError> { let _guard = SKILL_MUTATION_LOCK .lock() @@ -14418,46 +14832,149 @@ pub async fn acp_delete_agent_skill( "{agent_type} skills are not supported in Settings yet" ))); }; - let id = validate_skill_id(&skill_id)?; - let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; - - let skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) + let id = validate_skill_lookup_id(agent_type, &skill_id)?; + if agent_type == AgentType::Codex { + let config = load_codex_skill_config()?; + if codex_plugin_skill_by_id(&id, &config)?.is_some() { + return Err(AcpError::protocol(format!( + "skill '{id}' is managed or read-only and cannot be deleted here" + ))); + } + } + let topology = SkillRootTopology::build( + workspace_path.as_deref(), + data_dir, + agent_type != AgentType::Codex, + )?; + let plans = skill_root_plans( + agent_type, + scope, + workspace_path.as_deref(), + data_dir, + &topology, + )?; + let inventory = list_skills_from_plans(scope, &plans, spec.kind)?; + let planned = locate_skill_in_inventory(&inventory, &id) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; - if is_read_only_skill_path(agent_type, Path::new(&skill.path)) { + let skill = &planned.item; + if planned.residence == SkillResidence::LegacyVault + || skill_resolves_to_legacy_vault(skill, &topology) + || is_codeg_managed_skill(skill) + || is_read_only_skill_path(agent_type, Path::new(&skill.path)) + { return Err(AcpError::protocol(format!( - "skill '{id}' is a built-in system skill and cannot be deleted" + "skill '{id}' is managed or read-only and cannot be deleted here" ))); } let skill_path = PathBuf::from(&skill.path); - let peers = skill_peers(workspace_path.as_deref()); - // A shared root alias can own a vault outside this agent's lexical roots. - // Only a direct link to a known peer vault entry establishes ownership. - for peer in &peers { - for root in &peer.roots { - if !skill_root_is_shared(peer.agent, peer.scope, workspace_path.as_deref(), root)? { - continue; - } - if let Some(canonical) = locate_existing_skill( - &disabled_skill_root(root), - peer.kind, - &id, - peer.scope, - false, - ) { - let canonical_path = Path::new(&canonical.path); - if skill_path == canonical_path || skill_link_targets(&skill_path, canonical_path) { - preflight_shared_skill_owner(root, &peers)?; - preflight_disabled_skill_root(root, workspace_path.as_deref())?; - return delete_shared_skill(canonical_path, &peers, &id, |from, to| { - fs::rename(from, to) - }); - } - } - } + if agent_type == AgentType::Codex { + let content_path = skill_content_path(skill.layout, &skill_path); + delete_codex_skill_entry(&skill_path, &content_path) + } else { + remove_skill_entry(&skill_path) + .map_err(|e| AcpError::protocol(format!("failed to delete skill entry: {e}"))) } - remove_skill_entry(&skill_path) - .map_err(|e| AcpError::protocol(format!("failed to delete skill entry: {e}")))?; - Ok(()) +} + +#[cfg(feature = "tauri-runtime")] +fn resolve_tauri_skill_data_dir_result( + app_data_dir: Result, +) -> Result { + app_data_dir + .map(|path| crate::paths::resolve_effective_data_dir(&path)) + .map_err(|error| { + AcpError::protocol(format!( + "failed to resolve Codeg data directory for skill storage: {error}" + )) + }) +} + +#[cfg(feature = "tauri-runtime")] +fn tauri_skill_data_dir(app_handle: &tauri::AppHandle) -> Result { + resolve_tauri_skill_data_dir_result(app_handle.path().app_data_dir()) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_list_agent_skills( + agent_type: AgentType, + workspace_path: Option, + app_handle: tauri::AppHandle, +) -> Result { + let data_dir = tauri_skill_data_dir(&app_handle)?; + acp_list_agent_skills_core(agent_type, workspace_path, &data_dir).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_set_agent_skill_enabled( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + enabled: bool, + app_handle: tauri::AppHandle, +) -> Result { + let data_dir = tauri_skill_data_dir(&app_handle)?; + acp_set_agent_skill_enabled_core( + agent_type, + scope, + skill_id, + workspace_path, + enabled, + &data_dir, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_read_agent_skill( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + app_handle: tauri::AppHandle, +) -> Result { + let data_dir = tauri_skill_data_dir(&app_handle)?; + acp_read_agent_skill_core(agent_type, scope, skill_id, workspace_path, &data_dir).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_save_agent_skill( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + content: String, + workspace_path: Option, + layout: Option, + app_handle: tauri::AppHandle, +) -> Result { + let data_dir = tauri_skill_data_dir(&app_handle)?; + acp_save_agent_skill_core( + agent_type, + scope, + skill_id, + content, + workspace_path, + layout, + &data_dir, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_delete_agent_skill( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + app_handle: tauri::AppHandle, +) -> Result<(), AcpError> { + let data_dir = tauri_skill_data_dir(&app_handle)?; + acp_delete_agent_skill_core(agent_type, scope, skill_id, workspace_path, &data_dir).await } pub(crate) async fn opencode_list_plugins_core() -> Result { @@ -16804,64 +17321,351 @@ wire_api = "chat" dir } + fn test_skill_data_dir() -> &'static Path { + static DATA_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DATA_DIR + .get_or_init(|| unique_test_dir("skill-data")) + .as_path() + } + + async fn acp_list_agent_skills( + agent_type: AgentType, + workspace_path: Option, + ) -> Result { + acp_list_agent_skills_core(agent_type, workspace_path, test_skill_data_dir()).await + } + + async fn acp_set_agent_skill_enabled( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + enabled: bool, + ) -> Result { + acp_set_agent_skill_enabled_core( + agent_type, + scope, + skill_id, + workspace_path, + enabled, + test_skill_data_dir(), + ) + .await + } + + async fn acp_read_agent_skill( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + ) -> Result { + acp_read_agent_skill_core( + agent_type, + scope, + skill_id, + workspace_path, + test_skill_data_dir(), + ) + .await + } + + async fn acp_save_agent_skill( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + content: String, + workspace_path: Option, + layout: Option, + ) -> Result { + acp_save_agent_skill_core( + agent_type, + scope, + skill_id, + content, + workspace_path, + layout, + test_skill_data_dir(), + ) + .await + } + + async fn acp_delete_agent_skill( + agent_type: AgentType, + scope: AgentSkillScope, + skill_id: String, + workspace_path: Option, + ) -> Result<(), AcpError> { + acp_delete_agent_skill_core( + agent_type, + scope, + skill_id, + workspace_path, + test_skill_data_dir(), + ) + .await + } + #[test] - fn kimi_code_skill_storage_spec_targets_kimi_home() { - // `resolve_kimi_code_home_dir()` reads the process-wide `$HOME` (when - // `KIMI_CODE_HOME` is unset), and other tests mutate HOME via `temp_env`. - // Pin it (and clear `KIMI_CODE_HOME`) so the spec and the expected path - // resolve against one consistent home instead of racing a concurrent - // HOME-mutating test. Deriving `expected` from the same production helper - // keeps it correct on Windows, where `dirs::home_dir()` ignores HOME. + fn independent_private_skill_is_not_codeg_managed_just_because_its_id_exists_centrally() { let tmp = tempfile::tempdir().expect("tempdir"); - temp_env::with_vars( - [ - ("HOME", Some(tmp.path())), - ("KIMI_CODE_HOME", None::<&std::path::Path>), - ], - || { - let spec = - skill_storage_spec(AgentType::KimiCode).expect("Kimi Code supports skills"); - assert_eq!(spec.kind, SkillStorageKind::SkillDirectoryOnly); - assert_eq!(spec.project_rel_dirs, vec![".kimi-code/skills"]); - let expected = - crate::parsers::kimi_code::resolve_kimi_code_home_dir().join("skills"); - assert_eq!(spec.global_dirs, vec![expected]); - }, + let central_root = tmp.path().join("codeg-skills"); + let private_skill = tmp.path().join("private-skills/demo"); + fs::create_dir_all(central_root.join("demo")).expect("create central skill"); + fs::write(central_root.join("demo/SKILL.md"), "central").expect("write central skill"); + fs::create_dir_all(&private_skill).expect("create private skill"); + fs::write(private_skill.join("SKILL.md"), "private").expect("write private skill"); + + let skill = build_skill_item( + "demo".into(), + AgentSkillScope::Global, + AgentSkillLayout::SkillDirectory, + private_skill, + true, ); + + assert!(!is_codeg_managed_skill_at(&skill, ¢ral_root)); } + #[cfg(unix)] #[test] - fn pi_skill_storage_spec_targets_pi_agent_dir() { - // `pi_agent_dir()` and `home_dir_or_default()` both read the process-wide - // `$HOME`, and other tests mutate HOME via `temp_env`. Pin it (and clear - // the BYO `PI_CODING_AGENT_DIR` override) so this test serializes against - // those mutators through temp_env's shared lock and reads one consistent - // home for both the spec and the expected paths. Deriving `expected` from - // the same production helpers keeps it correct on Windows, where - // `dirs::home_dir()` ignores the pinned HOME. + fn linked_private_skill_resolving_into_central_root_is_codeg_managed() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().expect("tempdir"); - temp_env::with_vars( - [ - ("HOME", Some(tmp.path())), - ("PI_CODING_AGENT_DIR", None::<&std::path::Path>), - ], - || { - let spec = skill_storage_spec(AgentType::Pi).expect("Pi supports skills"); - // pi's native dir accepts standalone `.md` files, like Codex. - assert_eq!(spec.kind, SkillStorageKind::SkillDirectoryOrMarkdownFile); - assert_eq!(spec.project_rel_dirs, vec![".pi/skills", ".agents/skills"]); - // Native pi dir first (preferred link target), shared store second. - let expected = vec![ - pi_agent_dir().join("skills"), - home_dir_or_default().join(".agents").join("skills"), - ]; - assert_eq!(spec.global_dirs, expected); - }, + let central_root = tmp.path().join("codeg-skills"); + let central_skill = central_root.join("demo"); + let private_skill = tmp.path().join("private-skills/demo"); + fs::create_dir_all(¢ral_skill).expect("create central skill"); + fs::write(central_skill.join("SKILL.md"), "central").expect("write central skill"); + fs::create_dir_all(private_skill.parent().expect("private root")) + .expect("create private root"); + symlink(¢ral_skill, &private_skill).expect("link managed skill"); + + let skill = build_skill_item( + "demo".into(), + AgentSkillScope::Global, + AgentSkillLayout::SkillDirectory, + private_skill, + true, ); + + assert!(is_codeg_managed_skill_at(&skill, ¢ral_root)); } + #[cfg(feature = "tauri-runtime")] #[test] - fn deepseek_skill_storage_spec_mirrors_dsh_skill_roots() { + fn tauri_skill_data_dir_error_does_not_fall_back_to_current_directory() { + let error = resolve_tauri_skill_data_dir_result(Err::("path unavailable")) + .expect_err("missing app data directory must fail closed"); + + assert!(error.to_string().contains("path unavailable")); + } + + #[test] + fn shared_project_skill_is_reported_without_any_filesystem_mutation() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let shared = workspace.join(".agents/skills/demo"); + fs::create_dir_all(&shared).expect("create shared skill"); + fs::write(shared.join("SKILL.md"), "---\nname: demo\n---\n").expect("write skill"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Gemini, + Some(workspace.to_string_lossy().into_owned()), + &data_dir, + )) + .expect("list shared skill"); + let item = listed + .skills + .iter() + .find(|item| item.id == "demo" && item.scope == AgentSkillScope::Project) + .expect("shared skill listed"); + assert!(item.enabled); + assert!(!item.can_toggle); + assert_eq!(item.toggle_reason, Some(AgentSkillToggleReason::SharedRoot)); + + let error = runtime + .block_on(acp_set_agent_skill_enabled_core( + AgentType::Gemini, + AgentSkillScope::Project, + "demo".into(), + Some(workspace.to_string_lossy().into_owned()), + false, + &data_dir, + )) + .expect_err("shared root must not be moved"); + assert!(error.to_string().contains("shared skill root")); + assert!(shared.join("SKILL.md").is_file()); + assert!(!disabled_skill_root(&workspace.join(".agents/skills")).exists()); + assert!(!data_dir.exists()); + } + + #[test] + fn project_private_skill_uses_a_vault_outside_the_workspace() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let skill = workspace.join(".grok/skills/demo"); + fs::create_dir_all(&skill).expect("create private skill"); + fs::write(skill.join("SKILL.md"), "---\nname: demo\n---\n").expect("write skill"); + + let disabled = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace.to_string_lossy().into_owned()), + false, + &data_dir, + )) + .expect("disable private project skill"); + + let disabled_path = PathBuf::from(disabled.path); + assert!(disabled_path.starts_with(data_dir.join("skill-vaults/v1"))); + assert!(!disabled_path.starts_with(&workspace)); + assert!(disabled_path.join("SKILL.md").is_file()); + assert!(!skill.exists()); + assert!(!workspace.join(".grok/.skills.codeg-disabled").exists()); + } + + #[test] + fn private_vault_identity_distinguishes_agent_and_root() { + let tmp = tempfile::tempdir().expect("tempdir"); + let first = private_skill_vault( + AgentType::ClaudeCode, + AgentSkillScope::Global, + None, + tmp.path(), + &tmp.path().join("one/skills"), + ) + .expect("first vault"); + let second = private_skill_vault( + AgentType::ClaudeCode, + AgentSkillScope::Global, + None, + tmp.path(), + &tmp.path().join("two/skills"), + ) + .expect("second vault"); + let other_agent = private_skill_vault( + AgentType::Grok, + AgentSkillScope::Global, + None, + tmp.path(), + &tmp.path().join("one/skills"), + ) + .expect("other-agent vault"); + assert_ne!(first, second); + assert_ne!(first, other_agent); + } + + #[test] + fn legacy_disabled_skill_is_visible_but_never_automatically_moved() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let root = workspace.join(".grok/skills"); + let legacy = disabled_skill_root(&root).join("demo"); + fs::create_dir_all(&legacy).expect("create legacy skill"); + fs::write(legacy.join("SKILL.md"), "---\nname: demo\n---\n").expect("write skill"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace.to_string_lossy().into_owned()), + &data_dir, + )) + .expect("list legacy skill"); + let item = listed + .skills + .iter() + .find(|item| item.id == "demo") + .expect("legacy skill listed"); + assert!(!item.enabled); + assert!(!item.can_toggle); + assert_eq!( + item.toggle_reason, + Some(AgentSkillToggleReason::LegacyState) + ); + + runtime + .block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace.to_string_lossy().into_owned()), + true, + &data_dir, + )) + .expect_err("legacy state requires manual recovery"); + assert!(legacy.join("SKILL.md").is_file()); + assert!(!root.join("demo").exists()); + assert!(!data_dir.exists()); + } + + #[test] + fn kimi_code_skill_storage_spec_targets_kimi_home() { + // `resolve_kimi_code_home_dir()` reads the process-wide `$HOME` (when + // `KIMI_CODE_HOME` is unset), and other tests mutate HOME via `temp_env`. + // Pin it (and clear `KIMI_CODE_HOME`) so the spec and the expected path + // resolve against one consistent home instead of racing a concurrent + // HOME-mutating test. Deriving `expected` from the same production helper + // keeps it correct on Windows, where `dirs::home_dir()` ignores HOME. + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_vars( + [ + ("HOME", Some(tmp.path())), + ("KIMI_CODE_HOME", None::<&std::path::Path>), + ], + || { + let spec = + skill_storage_spec(AgentType::KimiCode).expect("Kimi Code supports skills"); + assert_eq!(spec.kind, SkillStorageKind::SkillDirectoryOnly); + assert_eq!(spec.project_rel_dirs, vec![".kimi-code/skills"]); + let expected = + crate::parsers::kimi_code::resolve_kimi_code_home_dir().join("skills"); + assert_eq!(spec.global_dirs, vec![expected]); + }, + ); + } + + #[test] + fn pi_skill_storage_spec_targets_pi_agent_dir() { + // `pi_agent_dir()` and `home_dir_or_default()` both read the process-wide + // `$HOME`, and other tests mutate HOME via `temp_env`. Pin it (and clear + // the BYO `PI_CODING_AGENT_DIR` override) so this test serializes against + // those mutators through temp_env's shared lock and reads one consistent + // home for both the spec and the expected paths. Deriving `expected` from + // the same production helpers keeps it correct on Windows, where + // `dirs::home_dir()` ignores the pinned HOME. + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_vars( + [ + ("HOME", Some(tmp.path())), + ("PI_CODING_AGENT_DIR", None::<&std::path::Path>), + ], + || { + let spec = skill_storage_spec(AgentType::Pi).expect("Pi supports skills"); + // pi's native dir accepts standalone `.md` files, like Codex. + assert_eq!(spec.kind, SkillStorageKind::SkillDirectoryOrMarkdownFile); + assert_eq!(spec.project_rel_dirs, vec![".pi/skills", ".agents/skills"]); + // Native pi dir first (preferred link target), shared store second. + let expected = vec![ + pi_agent_dir().join("skills"), + home_dir_or_default().join(".agents").join("skills"), + ]; + assert_eq!(spec.global_dirs, expected); + }, + ); + } + + #[test] + fn deepseek_skill_storage_spec_mirrors_dsh_skill_roots() { // Both resolvers read the process-wide `$HOME` when their env override // is unset, and other tests mutate HOME via `temp_env`. Pin it (and // clear both overrides) so the spec and the expected paths resolve @@ -16913,1780 +17717,67 @@ wire_api = "chat" let nested = repo.join("packages").join("app"); std::fs::create_dir_all(&nested).expect("create nested"); // A linked worktree records `.git` as a FILE, which upstream's - // `pathExists` accepts — so must this. - std::fs::write(repo.join(".git"), "gitdir: /elsewhere\n").expect("write .git file"); - - let dirs = scoped_skill_dirs( - AgentType::DeepSeek, - AgentSkillScope::Project, - Some(nested.to_str().expect("utf-8 path")), - ) - .expect("project dirs"); - assert_eq!( - dirs, - vec![repo.join(".dsh/skills"), repo.join(".agents/skills")] - ); - - // No `.git` anywhere above ⇒ fall back to the workspace itself. - let bare = tmp.path().join("bare"); - std::fs::create_dir_all(&bare).expect("create bare"); - let fallback = scoped_skill_dirs( - AgentType::DeepSeek, - AgentSkillScope::Project, - Some(bare.to_str().expect("utf-8 path")), - ) - .expect("fallback dirs"); - assert_eq!(fallback[0], bare.join(".dsh/skills")); - - // Every other agent keeps the plain workspace-relative layout. - let codex = scoped_skill_dirs( - AgentType::Codex, - AgentSkillScope::Project, - Some(nested.to_str().expect("utf-8 path")), - ) - .expect("codex dirs"); - assert_eq!(codex[0], nested.join(".codex/skills")); - - // ...and the LIST path must resolve the same base as the WRITE path: - // a skill saved from the nested workspace lands at the repo root, so - // listing the workspace directly would show it as missing. - let saved = repo.join(".dsh/skills").join("demo"); - std::fs::create_dir_all(&saved).expect("create skill dir"); - std::fs::write(saved.join("SKILL.md"), "---\nname: demo\n---\nbody\n") - .expect("write SKILL.md"); - let listed = tokio::runtime::Runtime::new() - .expect("runtime") - .block_on(acp_list_agent_skills( - AgentType::DeepSeek, - Some(nested.to_string_lossy().to_string()), - )) - .expect("list skills"); - assert!( - listed.skills.iter().any(|s| s.id == "demo"), - "skill saved at the git root must be listed from a nested workspace: {:?}", - listed.skills - ); - assert!( - listed - .locations - .iter() - .any(|l| l.path == repo.join(".dsh/skills").to_string_lossy()), - "the listed project location must be the git root: {:?}", - listed.locations - ); - } - - #[test] - fn skill_state_active_entry_wins_over_disabled_entries_across_roots() { - let tmp = tempfile::tempdir().expect("tempdir"); - let first = tmp.path().join("first/skills"); - let second = tmp.path().join("second/skills"); - std::fs::create_dir_all(first.join("demo")).expect("create active skill"); - std::fs::write(first.join("demo/SKILL.md"), "active\n").expect("write active skill"); - std::fs::create_dir_all(disabled_skill_root(&second).join("demo")) - .expect("create disabled skill"); - std::fs::write( - disabled_skill_root(&second).join("demo/SKILL.md"), - "disabled\n", - ) - .expect("write disabled skill"); - - assert_eq!( - disabled_skill_root(&first), - tmp.path().join("first/.skills.codeg-disabled") - ); - - let roots = [second, first.clone()]; - let listed = list_skills_from_roots( - AgentSkillScope::Global, - &roots, - SkillStorageKind::SkillDirectoryOnly, - ) - .expect("list skills"); - let located = locate_existing_skill_across_dirs( - &roots, - SkillStorageKind::SkillDirectoryOnly, - "demo", - AgentSkillScope::Global, - ) - .expect("locate skill"); - - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].path, first.join("demo").to_string_lossy()); - assert!(listed[0].enabled); - assert!(listed[0].can_toggle); - assert_eq!(located.path, first.join("demo").to_string_lossy()); - assert!(located.enabled); - } - - #[test] - fn skill_state_lists_and_locates_disabled_directory_layout() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let disabled = disabled_skill_root(&root).join("demo"); - std::fs::create_dir_all(&disabled).expect("create disabled skill"); - std::fs::write(disabled.join("SKILL.md"), "disabled\n") - .expect("write disabled skill"); - - let listed = list_skills_from_roots( - AgentSkillScope::Project, - std::slice::from_ref(&root), - SkillStorageKind::SkillDirectoryOnly, - ) - .expect("list skills"); - let located = locate_existing_skill_across_dirs( - std::slice::from_ref(&root), - SkillStorageKind::SkillDirectoryOnly, - "demo", - AgentSkillScope::Project, - ) - .expect("locate disabled skill"); - - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, "demo"); - assert_eq!(listed[0].layout, AgentSkillLayout::SkillDirectory); - assert_eq!(listed[0].path, disabled.to_string_lossy()); - assert!(!listed[0].enabled); - assert!(listed[0].can_toggle); - assert_eq!(located.path, disabled.to_string_lossy()); - assert!(!located.enabled); - } - - #[test] - fn skill_state_lists_and_locates_disabled_markdown_layout() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let disabled = disabled_skill_root(&root).join("flat.md"); - std::fs::create_dir_all(disabled.parent().expect("disabled parent")) - .expect("create disabled vault"); - std::fs::write(&disabled, "disabled\n").expect("write disabled skill"); - - let listed = list_skills_from_roots( - AgentSkillScope::Global, - std::slice::from_ref(&root), - SkillStorageKind::SkillDirectoryOrMarkdownFile, - ) - .expect("list skills"); - let located = locate_existing_skill_across_dirs( - std::slice::from_ref(&root), - SkillStorageKind::SkillDirectoryOrMarkdownFile, - "flat", - AgentSkillScope::Global, - ) - .expect("locate disabled skill"); - - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, "flat"); - assert_eq!(listed[0].layout, AgentSkillLayout::MarkdownFile); - assert_eq!(listed[0].path, disabled.to_string_lossy()); - assert!(!listed[0].enabled); - assert_eq!(located.layout, AgentSkillLayout::MarkdownFile); - assert!(!located.enabled); - } - - #[test] - fn codex_system_skill_is_read_only_but_toggleable() { - let tmp = tempfile::tempdir().expect("tempdir"); - temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { - let system_skill = tmp.path().join("skills/.system/task1a-system-demo"); - std::fs::create_dir_all(&system_skill).expect("create system skill"); - std::fs::write(system_skill.join("SKILL.md"), "system\n") - .expect("write system skill"); - - let listed = tokio::runtime::Runtime::new() - .expect("runtime") - .block_on(acp_list_agent_skills(AgentType::Codex, None)) - .expect("list skills"); - let item = listed - .skills - .iter() - .find(|item| item.id == "task1a-system-demo") - .expect("listed system skill"); - - assert!(item.enabled); - assert!(item.read_only); - assert!(item.can_toggle); - }); - } - - #[test] - fn skill_state_antigravity_cli_skill_cannot_toggle() { - let tmp = tempfile::tempdir().expect("tempdir"); - temp_env::with_var("GEMINI_HOME", Some(tmp.path()), || { - let cli_skill = tmp - .path() - .join("antigravity-cli/skills/task1a-antigravity-cli-demo"); - std::fs::create_dir_all(&cli_skill).expect("create CLI skill"); - std::fs::write(cli_skill.join("SKILL.md"), "CLI-owned\n") - .expect("write CLI skill"); - - let listed = tokio::runtime::Runtime::new() - .expect("runtime") - .block_on(acp_list_agent_skills(AgentType::Antigravity, None)) - .expect("list skills"); - let item = listed - .skills - .iter() - .find(|item| item.id == "task1a-antigravity-cli-demo") - .expect("listed Antigravity CLI skill"); - - assert!(item.enabled); - assert!(item.read_only); - assert!(!item.can_toggle); - }); - } - - #[test] - fn skill_enabled_private_directory_round_trips_through_disabled_vault() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let skill = root.join("demo"); - std::fs::create_dir_all(&skill).expect("create skill"); - std::fs::write( - skill.join("SKILL.md"), - "---\nname: demo\ndescription: private demo\n---\nbody\n", - ) - .expect("write skill"); - std::fs::write(skill.join("asset.txt"), "asset").expect("write asset"); - - let disabled = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, - AgentSkillScope::Global, - "demo", - false, - ) - .expect("disable skill"); - - assert!(!root.join("demo").exists()); - assert!( - disabled_skill_root(&root) - .join("demo") - .join("SKILL.md") - .is_file() - ); - assert_eq!( - std::fs::read_to_string(disabled_skill_root(&root).join("demo/asset.txt")) - .expect("read asset"), - "asset" - ); - assert!(!disabled.enabled); - - let listed = list_skills_from_roots( - AgentSkillScope::Global, - std::slice::from_ref(&root), - SkillStorageKind::SkillDirectoryOnly, - ) - .expect("list disabled skill"); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, "demo"); - assert!(!listed[0].enabled); - - let enabled = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, - AgentSkillScope::Global, - "demo", - true, - ) - .expect("enable skill"); - - assert!(root.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&root).join("demo").exists()); - assert!(enabled.enabled); - } - - #[test] - fn skill_enabled_private_markdown_file_round_trips_without_renaming_id() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - std::fs::create_dir_all(&root).expect("create skills root"); - std::fs::write( - root.join("flat.md"), - "---\nname: flat\ndescription: flat demo\n---\nbody\n", - ) - .expect("write flat skill"); - - let disabled = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOrMarkdownFile, - AgentSkillScope::Project, - "flat", - false, - ) - .expect("disable flat skill"); - - assert!(!root.join("flat.md").exists()); - assert!(disabled_skill_root(&root).join("flat.md").is_file()); - assert_eq!(disabled.layout, AgentSkillLayout::MarkdownFile); - assert_eq!(disabled.scope, AgentSkillScope::Project); - assert!(!disabled.enabled); - - let enabled = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOrMarkdownFile, - AgentSkillScope::Project, - "flat", - true, - ) - .expect("enable flat skill"); - - assert!(root.join("flat.md").is_file()); - assert!(enabled.enabled); - assert_eq!(enabled.id, "flat"); - } - - #[test] - fn skill_enabled_private_listing_prefers_active_and_serializes_state() { - let tmp = tempfile::tempdir().expect("tempdir"); - let first = tmp.path().join("first/skills"); - let second = tmp.path().join("second/skills"); - std::fs::create_dir_all(first.join("demo")).expect("create active skill"); - std::fs::write(first.join("demo/SKILL.md"), "active\n").expect("write active skill"); - std::fs::create_dir_all(disabled_skill_root(&second).join("demo")) - .expect("create disabled skill"); - std::fs::write( - disabled_skill_root(&second).join("demo/SKILL.md"), - "disabled\n", - ) - .expect("write disabled skill"); - - assert_eq!( - disabled_skill_root(&first), - tmp.path().join("first/.skills.codeg-disabled") - ); - let listed = list_skills_from_roots( - AgentSkillScope::Global, - &[second, first.clone()], - SkillStorageKind::SkillDirectoryOnly, - ) - .expect("list skills"); - - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].path, first.join("demo").to_string_lossy()); - assert!(listed[0].enabled); - assert!(listed[0].can_toggle); - let json = serde_json::to_value(&listed[0]).expect("serialize skill"); - assert_eq!(json["enabled"], true); - assert_eq!(json["can_toggle"], true); - } - - #[test] - fn skill_enabled_private_repeated_requests_are_idempotent() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - std::fs::create_dir_all(root.join("demo")).expect("create skill"); - std::fs::write(root.join("demo/SKILL.md"), "body\n").expect("write skill"); - - for _ in 0..2 { - let skill = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, - AgentSkillScope::Global, - "demo", - false, - ) - .expect("disable skill"); - assert!(!skill.enabled); - } - for _ in 0..2 { - let skill = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, - AgentSkillScope::Global, - "demo", - true, - ) - .expect("enable skill"); - assert!(skill.enabled); - } - - assert_eq!( - std::fs::read_to_string(root.join("demo/SKILL.md")).expect("read skill"), - "body\n" - ); - assert!(!disabled_skill_root(&root).join("demo").exists()); - } - - #[test] - fn skill_enabled_private_collision_is_rejected_before_move() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let disabled = disabled_skill_root(&root); - std::fs::create_dir_all(root.join("demo")).expect("create active skill"); - std::fs::write(root.join("demo/SKILL.md"), "active\n").expect("write active skill"); - std::fs::create_dir_all(disabled.join("demo")).expect("create disabled collision"); - std::fs::write(disabled.join("demo/SKILL.md"), "disabled\n") - .expect("write disabled collision"); - - let error = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, - AgentSkillScope::Global, - "demo", - false, - ) - .expect_err("collision must fail"); - - assert!(error.to_string().contains("collision")); - assert_eq!( - std::fs::read_to_string(root.join("demo/SKILL.md")).expect("read active"), - "active\n" - ); - assert_eq!( - std::fs::read_to_string(disabled.join("demo/SKILL.md")).expect("read disabled"), - "disabled\n" - ); - } - - #[test] - fn skill_enabled_private_disabled_skill_remains_readable_editable_and_deletable() { - let tmp = tempfile::tempdir().expect("tempdir"); - temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { - let root = tmp.path().join("skills"); - std::fs::create_dir_all(root.join("task1-disabled-demo")) - .expect("create skill"); - std::fs::write( - root.join("task1-disabled-demo/SKILL.md"), - "original\n", - ) - .expect("write skill"); - set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOrMarkdownFile, - AgentSkillScope::Global, - "task1-disabled-demo", - false, - ) - .expect("disable skill"); - - let runtime = tokio::runtime::Runtime::new().expect("runtime"); - let read = runtime - .block_on(acp_read_agent_skill( - AgentType::Codex, - AgentSkillScope::Global, - "task1-disabled-demo".to_string(), - None, - )) - .expect("read disabled skill"); - assert_eq!(read.content, "original\n"); - assert!(!read.skill.enabled); - - let saved = runtime - .block_on(acp_save_agent_skill( - AgentType::Codex, - AgentSkillScope::Global, - "task1-disabled-demo".to_string(), - "updated\n".to_string(), - None, - None, - )) - .expect("save disabled skill"); - assert!(!saved.enabled); - assert!(!root.join("task1-disabled-demo").exists()); - assert_eq!( - std::fs::read_to_string( - disabled_skill_root(&root).join("task1-disabled-demo/SKILL.md") - ) - .expect("read updated skill"), - "updated\n" - ); - - runtime - .block_on(acp_delete_agent_skill( - AgentType::Codex, - AgentSkillScope::Global, - "task1-disabled-demo".to_string(), - None, - )) - .expect("delete disabled skill"); - assert!(!disabled_skill_root(&root) - .join("task1-disabled-demo") - .exists()); - }); - } - - #[test] - fn skill_enabled_private_command_is_idempotent() { - let tmp = tempfile::tempdir().expect("tempdir"); - temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { - let root = tmp.path().join("skills"); - std::fs::create_dir_all(root.join("task1-command-demo")).expect("create skill"); - std::fs::write(root.join("task1-command-demo/SKILL.md"), "body\n") - .expect("write skill"); - let runtime = tokio::runtime::Runtime::new().expect("runtime"); - - for enabled in [false, false, true, true] { - let item = runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::Codex, - AgentSkillScope::Global, - "task1-command-demo".to_string(), - None, - enabled, - )) - .expect("toggle skill"); - assert_eq!(item.enabled, enabled); - } - }); - } - - #[cfg(unix)] - #[test] - fn skill_enabled_private_symlink_round_trip_preserves_link_and_target() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let target = tmp.path().join("target"); - fs::create_dir_all(&root).unwrap(); - fs::create_dir_all(&target).unwrap(); - fs::write(target.join("SKILL.md"), "linked body").unwrap(); - std::os::unix::fs::symlink("../target", root.join("linked")).unwrap(); - - for enabled in [false, true] { - let item = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, - AgentSkillScope::Global, - "linked", - enabled, - ) - .unwrap(); - assert_eq!(fs::read_link(&item.path).unwrap(), Path::new("../target")); - assert_eq!( - fs::read_to_string(target.join("SKILL.md")).unwrap(), - "linked body" - ); - assert_eq!(item.enabled, enabled); - } - } - - #[test] - fn skill_enabled_private_collision_checks_other_layout_before_mutation() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let vault = disabled_skill_root(&root); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "active").unwrap(); - fs::create_dir_all(&vault).unwrap(); - fs::write(vault.join("demo.md"), "disabled").unwrap(); - - let error = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOrMarkdownFile, - AgentSkillScope::Global, - "demo", - false, - ) - .unwrap_err(); - assert!(error.to_string().contains("collision")); - assert_eq!( - fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "active" - ); - assert_eq!( - fs::read_to_string(vault.join("demo.md")).unwrap(), - "disabled" - ); - } - - #[cfg(unix)] - #[test] - fn skill_enabled_private_relative_symlink_target_change_is_rejected_before_move() { - for vault_exists in [false, true] { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let vault = disabled_skill_root(&root); - fs::create_dir_all(root.join("target")).unwrap(); - fs::write(root.join("target/SKILL.md"), "original target").unwrap(); - std::os::unix::fs::symlink("target", root.join("demo")).unwrap(); - if vault_exists { - fs::create_dir_all(vault.join("target")).unwrap(); - fs::write(vault.join("target/SKILL.md"), "different target").unwrap(); - } - let error = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, - AgentSkillScope::Global, - "demo", - false, - ) - .unwrap_err(); - assert!(error.to_string().contains("relative symlink")); - assert_eq!( - fs::read_link(root.join("demo")).unwrap(), - Path::new("target") - ); - assert_eq!( - fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "original target" - ); - assert!(!vault.join("demo").exists()); - assert_eq!(vault.exists(), vault_exists); - } - } - - #[cfg(unix)] - #[test] - fn skill_enabled_private_absolute_symlink_round_trip_preserves_link() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let target = tmp.path().join("target.md"); - fs::create_dir_all(&root).unwrap(); - fs::write(&target, "absolute target").unwrap(); - std::os::unix::fs::symlink(&target, root.join("demo.md")).unwrap(); - for enabled in [false, true] { - let item = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOrMarkdownFile, - AgentSkillScope::Global, - "demo", - enabled, - ) - .unwrap(); - assert_eq!(fs::read_link(&item.path).unwrap(), target); - assert_eq!(fs::read_to_string(item.path).unwrap(), "absolute target"); - } - } - - #[cfg(unix)] - #[test] - fn skill_enabled_private_enable_rejects_dangling_destination_link() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path().join("skills"); - let vault = disabled_skill_root(&root); - fs::create_dir_all(&root).unwrap(); - fs::create_dir_all(&vault).unwrap(); - fs::write(vault.join("demo.md"), "disabled").unwrap(); - std::os::unix::fs::symlink("missing", root.join("demo.md")).unwrap(); - - let error = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOrMarkdownFile, - AgentSkillScope::Global, - "demo", - true, - ) - .unwrap_err(); - assert!(error.to_string().contains("collision")); - assert_eq!( - fs::read_link(root.join("demo.md")).unwrap(), - Path::new("missing") - ); - assert_eq!( - fs::read_to_string(vault.join("demo.md")).unwrap(), - "disabled" - ); - } - - fn shared_skill_fixture(base: &Path) -> (PathBuf, Vec) { - let shared = base.join("shared/skills"); - fs::create_dir_all(shared.join("demo")).unwrap(); - fs::write(shared.join("demo/SKILL.md"), "shared").unwrap(); - let peers = [(AgentType::Codex, "a"), (AgentType::Pi, "b")] - .into_iter() - .map(|(agent, name)| SkillPeer { - agent, - scope: AgentSkillScope::Project, - kind: SkillStorageKind::SkillDirectoryOrMarkdownFile, - roots: vec![base.join(name).join("skills"), shared.clone()], - }) - .collect(); - (shared, peers) - } - - #[cfg(unix)] - #[test] - fn shared_skill_incoming_peer_link_is_rejected_before_canonical_move() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - let incoming_root = tmp.path().join("incoming/skills"); - fs::create_dir_all(&incoming_root).unwrap(); - std::os::unix::fs::symlink(shared.join("demo"), incoming_root.join("demo")).unwrap(); - peers.push(SkillPeer { - agent: AgentType::OpenCode, - scope: AgentSkillScope::Project, - kind: SkillStorageKind::SkillDirectoryOnly, - roots: vec![incoming_root.clone()], - }); - let listed = locate_existing_skill_across_dirs( - &peers[1].roots, - peers[1].kind, - "demo", - peers[1].scope, - ) - .unwrap(); - - let can_toggle = listed_skill_can_toggle(&peers[1], &peers, &listed).unwrap_or(false); - let result = set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "demo", - false, - create_skill_link, - ); - - assert!(!can_toggle, "capability must match the move preflight"); - assert!( - result - .unwrap_err() - .to_string() - .contains("incoming peer link"), - "the canonical move must be refused" - ); - assert_eq!( - fs::read_to_string(shared.join("demo/SKILL.md")).unwrap(), - "shared" - ); - assert_eq!( - fs::read_to_string(incoming_root.join("demo/SKILL.md")).unwrap(), - "shared" - ); - assert!(!disabled_skill_root(&shared).exists()); - assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); - } - - #[cfg(unix)] - #[test] - fn shared_skill_alias_incoming_peer_link_is_rejected_before_canonical_move() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - let incoming_root = tmp.path().join("incoming/skills"); - fs::create_dir_all(&incoming_root).unwrap(); - std::os::unix::fs::symlink(shared.join("demo"), incoming_root.join("alias")).unwrap(); - peers.push(SkillPeer { - agent: AgentType::OpenCode, - scope: AgentSkillScope::Project, - kind: SkillStorageKind::SkillDirectoryOnly, - roots: vec![incoming_root.clone()], - }); - let listed = locate_existing_skill_across_dirs( - &peers[1].roots, - peers[1].kind, - "demo", - peers[1].scope, - ) - .unwrap(); - - let can_toggle = listed_skill_can_toggle(&peers[1], &peers, &listed).unwrap_or(false); - let result = set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "demo", - false, - create_skill_link, - ); - - assert!(!can_toggle, "aliases must be included in the move preflight"); - assert!(result - .unwrap_err() - .to_string() - .contains("incoming peer link")); - assert_eq!( - fs::read_to_string(shared.join("demo/SKILL.md")).unwrap(), - "shared" - ); - assert_eq!( - fs::read_to_string(incoming_root.join("alias/SKILL.md")).unwrap(), - "shared" - ); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[cfg(unix)] - #[test] - fn shared_markdown_skill_alias_case_variant_incoming_link_is_rejected() { - let tmp = tempfile::tempdir().unwrap(); - let shared = tmp.path().join("shared/skills"); - let incoming_root = tmp.path().join("incoming/skills"); - fs::create_dir_all(&shared).unwrap(); - fs::create_dir_all(&incoming_root).unwrap(); - fs::write(shared.join("demo.md"), "shared markdown").unwrap(); - std::os::unix::fs::symlink(shared.join("demo.md"), incoming_root.join("alias.MD")) - .unwrap(); - let peers = vec![ - SkillPeer { - agent: AgentType::Codex, - scope: AgentSkillScope::Project, - kind: SkillStorageKind::SkillDirectoryOrMarkdownFile, - roots: vec![tmp.path().join("owner/skills"), shared.clone()], - }, - SkillPeer { - agent: AgentType::OpenCode, - scope: AgentSkillScope::Project, - kind: SkillStorageKind::SkillDirectoryOrMarkdownFile, - roots: vec![incoming_root.clone()], - }, - ]; - let listed = locate_existing_skill_across_dirs( - &peers[0].roots, - peers[0].kind, - "demo", - peers[0].scope, - ) - .unwrap(); - - let can_toggle = listed_skill_can_toggle(&peers[0], &peers, &listed).unwrap_or(false); - let result = set_shared_skill_enabled( - &peers[0], - &peers, - &shared, - "demo", - false, - create_skill_link, - ); - - assert!(!can_toggle, "case variants must be included in the preflight"); - assert!(result - .unwrap_err() - .to_string() - .contains("incoming peer link")); - assert_eq!(fs::read_to_string(shared.join("demo.md")).unwrap(), "shared markdown"); - assert_eq!( - fs::read_to_string(incoming_root.join("alias.MD")).unwrap(), - "shared markdown" - ); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[cfg(unix)] - #[test] - fn shared_peer_secondary_root_incoming_alias_is_rejected() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - fs::create_dir_all(&peers[0].roots[0]).unwrap(); - std::os::unix::fs::symlink( - shared.join("demo"), - peers[0].roots[0].join("alias"), - ) - .unwrap(); - let listed = locate_existing_skill_across_dirs( - &peers[1].roots, - peers[1].kind, - "demo", - peers[1].scope, - ) - .unwrap(); - - let can_toggle = listed_skill_can_toggle(&peers[1], &peers, &listed).unwrap_or(false); - let result = set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "demo", - false, - create_skill_link, - ); - - assert!( - !can_toggle, - "only the peer's shared root may be skipped by the preflight" - ); - assert!(result - .unwrap_err() - .to_string() - .contains("incoming peer link")); - assert_eq!( - fs::read_to_string(shared.join("demo/SKILL.md")).unwrap(), - "shared" - ); - assert_eq!( - fs::read_to_string(peers[0].roots[0].join("alias/SKILL.md")).unwrap(), - "shared" - ); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[cfg(unix)] - #[test] - fn private_skill_incoming_peer_link_is_rejected_before_canonical_move() { - use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; - let _registry_guard = hydrate_test_guard(); - let tmp = tempfile::tempdir().unwrap(); - let canonical_root = tmp.path().join("owner/skills"); - let incoming_root = tmp.path().join("peer/skills"); - fs::create_dir_all(canonical_root.join("demo")).unwrap(); - fs::write(canonical_root.join("demo/SKILL.md"), "private").unwrap(); - fs::create_dir_all(&incoming_root).unwrap(); - std::os::unix::fs::symlink( - canonical_root.join("demo"), - incoming_root.join("demo"), - ) - .unwrap(); - assert!(hydrate(&[ - shared_skill_custom_def("task6-private-owner", &canonical_root), - shared_skill_custom_def("task6-private-peer", &incoming_root), - ]) - .is_empty()); - let owner = AgentType::custom("task6-private-owner").unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let listed = runtime - .block_on(acp_list_agent_skills(owner, None)) - .unwrap() - .skills - .into_iter() - .find(|item| item.id == "demo") - .unwrap(); - let result = runtime.block_on(acp_set_agent_skill_enabled( - owner, - AgentSkillScope::Global, - "demo".into(), - None, - false, - )); - hydrate(&[]); - - assert!(!listed.can_toggle, "capability must match command execution"); - assert!(result - .unwrap_err() - .to_string() - .contains("incoming peer link")); - assert_eq!( - fs::read_to_string(canonical_root.join("demo/SKILL.md")).unwrap(), - "private" - ); - assert_eq!( - fs::read_to_string(incoming_root.join("demo/SKILL.md")).unwrap(), - "private" - ); - assert!(!disabled_skill_root(&canonical_root).exists()); - } - - #[cfg(unix)] - #[test] - fn cursor_global_skill_round_trip_keeps_legacy_vault_compatible() { - let tmp = tempfile::tempdir().unwrap(); - temp_env::with_vars( - [ - ("HOME", Some(tmp.path())), - ("CODEX_HOME", None::<&Path>), - ("GEMINI_HOME", None), - ("HERMES_HOME", None), - ("KIMI_CODE_HOME", None), - ("PI_CODING_AGENT_DIR", None), - ("DSH_HOME", None), - ("DSH_AGENTS_HOME", None), - ("QODER_CONFIG_DIR", None), - ("QODER_CLI_HOME", None), - ], - || { - let home = home_dir_or_default(); - let writable_root = home.join(".cursor/skills"); - let builtin_root = home.join(".cursor/skills-cursor"); - let legacy_vault = home.join(".cursor/.skills.codeg-disabled"); - fs::create_dir_all(writable_root.join("demo")).unwrap(); - fs::write(writable_root.join("demo/SKILL.md"), "cursor").unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let listed = runtime - .block_on(acp_list_agent_skills(AgentType::Cursor, None)) - .unwrap() - .skills - .into_iter() - .find(|item| item.id == "demo") - .unwrap(); - let disabled = runtime.block_on(acp_set_agent_skill_enabled( - AgentType::Cursor, - AgentSkillScope::Global, - "demo".into(), - None, - false, - )); - - assert!(listed.can_toggle); - assert_eq!(disabled_skill_root(&writable_root), legacy_vault); - assert_ne!( - disabled_skill_root(&writable_root), - disabled_skill_root(&builtin_root) - ); - let disabled = disabled.expect("Cursor global skill must disable"); - assert!(!disabled.enabled); - assert!(legacy_vault.join("demo/SKILL.md").is_file()); - let enabled = runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::Cursor, - AgentSkillScope::Global, - "demo".into(), - None, - true, - )) - .expect("Cursor global skill must re-enable"); - assert!(enabled.enabled); - assert!(writable_root.join("demo/SKILL.md").is_file()); - assert!(!legacy_vault.join("demo").exists()); - }, - ); - } - - #[test] - fn custom_skill_root_named_skills_cursor_keeps_legacy_disabled_entries() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("custom/skills-cursor"); - let legacy_vault = root.parent().unwrap().join(".skills.codeg-disabled"); - fs::create_dir_all(legacy_vault.join("demo")).unwrap(); - fs::write(legacy_vault.join("demo/SKILL.md"), "legacy").unwrap(); - - let listed = list_skills_from_roots( - AgentSkillScope::Global, - std::slice::from_ref(&root), - SkillStorageKind::SkillDirectoryOnly, - ) - .unwrap(); - - assert_eq!(disabled_skill_root(&root), legacy_vault); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, "demo"); - assert!(!listed[0].enabled); - } - - #[test] - fn cursor_builtin_skill_root_uses_distinct_vault() { - let root = home_dir_or_default().join(".cursor/skills-cursor"); - assert_eq!( - disabled_skill_root(&root), - home_dir_or_default().join(".cursor/.skills-cursor.codeg-disabled") - ); - } - - fn skill_capability_project_fixture(base: &Path) -> PathBuf { - let shared = base.join(".claude/skills"); - fs::create_dir_all(shared.join("capability-demo")).unwrap(); - fs::write(shared.join("capability-demo/SKILL.md"), "body").unwrap(); - shared - } - - fn skill_capability_list_project( - runtime: &tokio::runtime::Runtime, - agent: AgentType, - base: &Path, - ) -> AgentSkillItem { - runtime - .block_on(acp_list_agent_skills( - agent, - Some(base.to_string_lossy().into_owned()), - )) - .unwrap() - .skills - .into_iter() - .find(|item| item.scope == AgentSkillScope::Project && item.id == "capability-demo") - .unwrap() - } - - #[test] - fn skill_capability_enabled_shared_without_unique_peer_root_is_false() { - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let item = skill_capability_list_project(&runtime, AgentType::Cline, tmp.path()); - assert!(item.enabled); - assert!( - !item.can_toggle, - "Claude has no unique root to preserve its enabled state" - ); - assert!(shared.join("capability-demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).exists()); - assert!(!tmp.path().join(".cline").exists()); - } - - #[test] - fn skill_capability_feasible_shared_and_private_entries_are_true() { - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - let private = tmp.path().join(".codex/skills/capability-demo"); - fs::create_dir_all(&private).unwrap(); - fs::write(private.join("SKILL.md"), "private").unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - for agent in [AgentType::ClaudeCode, AgentType::Codex] { - assert!(skill_capability_list_project(&runtime, agent, tmp.path()).can_toggle); - } - assert!(!disabled_skill_root(&shared).exists()); - assert!(!tmp.path().join(".cline").exists()); - } - - #[test] - fn skill_capability_disabled_shared_restore_is_true_when_peers_enabled() { - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - let runtime = tokio::runtime::Runtime::new().unwrap(); - runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::ClaudeCode, - AgentSkillScope::Project, - "capability-demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - false, - )) - .unwrap(); - let item = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); - assert!(!item.enabled); - assert!(item.can_toggle); - assert!(!shared.join("capability-demo").exists()); - assert!(disabled_skill_root(&shared) - .join("capability-demo/SKILL.md") - .is_file()); - assert!(tmp - .path() - .join(".cline/skills/capability-demo/SKILL.md") - .is_file()); - } - - #[test] - fn skill_capability_disabled_shared_restore_is_false_when_peer_disabled() { - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - let runtime = tokio::runtime::Runtime::new().unwrap(); - for agent in [AgentType::ClaudeCode, AgentType::Cline] { - runtime - .block_on(acp_set_agent_skill_enabled( - agent, - AgentSkillScope::Project, - "capability-demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - false, - )) - .unwrap(); - } - let item = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); - assert!(!item.enabled); - assert!(!item.can_toggle, "restoring shared root would enable Cline"); - assert!(!shared.join("capability-demo").exists()); - assert!(disabled_skill_root(&shared) - .join("capability-demo/SKILL.md") - .is_file()); - assert!(fs::symlink_metadata(tmp.path().join(".cline/skills/capability-demo")).is_err()); - } - - #[cfg(unix)] - #[test] - fn skill_capability_planning_error_keeps_item_but_disables_toggle() { - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - fs::create_dir_all(tmp.path().join(".clinerules")).unwrap(); - std::os::unix::fs::symlink("missing", tmp.path().join(".clinerules/skills")).unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let item = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); - assert!(!item.can_toggle); - assert!(item.enabled); - assert!(shared.join("capability-demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[test] - fn skill_capability_unique_enable_ignores_unneeded_shared_restore_peers() { - use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; - let _registry_guard = hydrate_test_guard(); - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let workspace = Some(tmp.path().to_string_lossy().into_owned()); - for agent in [AgentType::ClaudeCode, AgentType::Cline] { - runtime - .block_on(acp_set_agent_skill_enabled( - agent, - AgentSkillScope::Project, - "capability-demo".into(), - workspace.clone(), - false, - )) - .unwrap(); - } - let nested = shared.join("nested/skills"); - fs::create_dir_all(&nested).unwrap(); - assert!(hydrate(&[shared_skill_custom_def("capability-nested-peer", &nested)]).is_empty()); - let listed = skill_capability_list_project(&runtime, AgentType::Cline, tmp.path()); - assert!(!tmp.path().join(".cline/skills/capability-demo").exists()); - let enabled = runtime.block_on(acp_set_agent_skill_enabled( - AgentType::Cline, - AgentSkillScope::Project, - "capability-demo".into(), - workspace, - true, - )); - hydrate(&[]); - assert!( - enabled.unwrap().enabled, - "execution can enable via Cline's unique root" - ); - assert!( - listed.can_toggle, - "unique enable does not restore the shared root" - ); - } - - #[test] - fn skill_capability_shared_peer_duplicate_is_false_before_fanout() { - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - let independent = tmp.path().join(".cline/skills/capability-demo"); - fs::create_dir_all(&independent).unwrap(); - fs::write(independent.join("SKILL.md"), "independent").unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let listed = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); - let error = runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::ClaudeCode, - AgentSkillScope::Project, - "capability-demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - false, - )) - .unwrap_err(); - assert!(error.to_string().contains("multiple active skills")); - assert!( - !listed.can_toggle, - "existing peer duplicate blocks the same fanout execution" - ); - assert!(shared.join("capability-demo/SKILL.md").is_file()); - assert_eq!( - fs::read_to_string(independent.join("SKILL.md")).unwrap(), - "independent" - ); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[test] - fn skill_capability_existing_fanout_destination_conflict_is_false() { - let tmp = tempfile::tempdir().unwrap(); - let shared = skill_capability_project_fixture(tmp.path()); - let peer_root = tmp.path().join(".cline/skills"); - fs::create_dir_all(&peer_root).unwrap(); - fs::write(peer_root.join("capability-demo"), "occupied").unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let listed = skill_capability_list_project(&runtime, AgentType::ClaudeCode, tmp.path()); - assert!( - !listed.can_toggle, - "existing destination conflicts are deterministic blockers" - ); - assert!(shared.join("capability-demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).exists()); - } - - fn shared_skill_custom_def( - id: &str, - root: &Path, - ) -> crate::acp::custom_registry::CustomAgentDef { - use crate::acp::custom_registry::{ - CustomAgentDef, CustomAgentSpec, CustomDistributionKind, NpxSpec, - }; - CustomAgentDef { - registry_id: id.into(), - name: id.into(), - description: String::new(), - version: "1.0.0".into(), - distribution_kind: CustomDistributionKind::Npx, - spec: CustomAgentSpec { - npx: Some(NpxSpec { - package: "test-agent@1.0.0".into(), - ..Default::default() - }), - ..Default::default() - }, - icon_url: None, - skills_shared_store: false, - skills_dir: Some(root.to_string_lossy().into_owned()), - source: Default::default(), - version_probe: None, - supports_mcp: true, - } - } - - #[test] - fn shared_skill_reparse_probe_is_accessible_from_acp() { - let tmp = tempfile::tempdir().unwrap(); - assert!(!crate::commands::experts::path_is_reparse_point(tmp.path())); - } - - #[test] - fn shared_skill_custom_owner_cannot_disable_another_agents_builtin_root() { - use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; - let _registry_guard = hydrate_test_guard(); - let tmp = tempfile::tempdir().unwrap(); - temp_env::with_var("GEMINI_HOME", Some(tmp.path()), || { - let root = tmp.path().join("antigravity-cli/skills"); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "builtin").unwrap(); - assert!(hydrate(&[shared_skill_custom_def("task2-readonly-owner", &root)]).is_empty()); - let result = - tokio::runtime::Runtime::new() - .unwrap() - .block_on(acp_set_agent_skill_enabled( - AgentType::custom("task2-readonly-owner").unwrap(), - AgentSkillScope::Global, - "demo".into(), - None, - false, - )); - hydrate(&[]); - assert!(result.unwrap_err().to_string().contains("read-only")); - assert_eq!( - fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "builtin" - ); - assert!(!disabled_skill_root(&root).exists()); - }); - } - - #[cfg(unix)] - #[test] - fn shared_skill_alias_roots_do_not_duplicate_followup_toggles() { - let tmp = tempfile::tempdir().unwrap(); - let shared = tmp.path().join(".claude/skills"); - let cline = tmp.path().join(".cline/skills"); - fs::create_dir_all(shared.join("demo")).unwrap(); - fs::write(shared.join("demo/SKILL.md"), "body").unwrap(); - fs::create_dir_all(&cline).unwrap(); - fs::create_dir_all(tmp.path().join(".clinerules")).unwrap(); - std::os::unix::fs::symlink(&cline, tmp.path().join(".clinerules/skills")).unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - for (agent, enabled) in [ - (AgentType::ClaudeCode, false), - (AgentType::Cline, false), - (AgentType::Cline, true), - (AgentType::ClaudeCode, true), - ] { - let item = runtime - .block_on(acp_set_agent_skill_enabled( - agent, - AgentSkillScope::Project, - "demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - enabled, - )) - .unwrap(); - assert_eq!(item.enabled, enabled); - } - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(fs::symlink_metadata(cline.join("demo")).is_err()); - } - - #[cfg(unix)] - #[test] - fn shared_skill_delete_via_alias_canonical_link_is_global() { - use crate::acp::custom_registry::{hydrate, hydrate_test_guard}; - let _registry_guard = hydrate_test_guard(); - let tmp = tempfile::tempdir().unwrap(); - temp_env::with_vars( - [ - ("HOME", Some(tmp.path())), - ("CODEX_HOME", None::<&Path>), - ("GEMINI_HOME", None), - ("PI_CODING_AGENT_DIR", None), - ("DSH_HOME", None), - ("DSH_AGENTS_HOME", None), - ("QODER_CONFIG_DIR", None), - ("QODER_CLI_HOME", None), - ], - || { - let shared = tmp.path().join(".agents/skills"); - let alias = tmp.path().join("custom/skills"); - fs::create_dir_all(shared.join("demo")).unwrap(); - fs::write(shared.join("demo/SKILL.md"), "body").unwrap(); - fs::create_dir_all(alias.parent().unwrap()).unwrap(); - std::os::unix::fs::symlink(&shared, &alias).unwrap(); - assert!( - hydrate(&[shared_skill_custom_def("task2-alias-owner", &alias)]).is_empty() - ); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let result = runtime.block_on(async { - acp_set_agent_skill_enabled( - AgentType::custom("task2-alias-owner").unwrap(), - AgentSkillScope::Global, - "demo".into(), - None, - false, - ) - .await?; - acp_delete_agent_skill( - AgentType::Codex, - AgentSkillScope::Global, - "demo".into(), - None, - ) - .await - }); - let peers = skill_peers(None); - hydrate(&[]); - result.unwrap(); - assert!( - !disabled_skill_root(&alias).join("demo").exists(), - "canonical installation remains" - ); - for peer in peers { - for root in peer.roots { - assert!( - fs::symlink_metadata(root.join("demo")).is_err(), - "leftover peer entry: {}", - root.display() - ); - } - } - }, - ); - } - - #[test] - fn shared_skill_fanout_isolates_and_reenables_selected_peer() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - for enabled in [false, true, false] { - let item = set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "demo", - enabled, - create_skill_link, - ) - .unwrap(); - assert_eq!(item.enabled, enabled); - assert!(peers[0].roots[0].join("demo/SKILL.md").is_file()); - assert_eq!(peers[1].roots[0].join("demo").exists(), enabled); - assert!(disabled_skill_root(&shared).join("demo/SKILL.md").is_file()); - assert!(!shared.join("demo").exists()); - for (peer, expected) in [(&peers[0], true), (&peers[1], enabled)] { - let items = list_skills_from_roots(peer.scope, &peer.roots, peer.kind).unwrap(); - assert_eq!(items[0].enabled, expected); - } - } - } - - #[test] - fn shared_skill_peer_without_unique_root_refuses_before_move() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - peers[0].roots = vec![shared.clone()]; - let error = - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) - .unwrap_err(); - assert!(error.to_string().contains("unique writable root")); - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[test] - fn shared_skill_selected_without_unique_root_can_restore_when_peers_enabled() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - peers[1].roots = vec![shared.clone()]; - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) - .unwrap(); - assert!(peers[0].roots[0].join("demo/SKILL.md").is_file()); - let item = - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", true, create_skill_link) - .unwrap(); - assert!(item.enabled); - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).join("demo").exists()); - assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); - } - - #[test] - fn shared_skill_restore_refuses_to_reenable_a_disabled_peer() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - peers[1].roots = vec![shared.clone()]; - for peer in [&peers[1], &peers[0]] { - set_shared_skill_enabled(peer, &peers, &shared, "demo", false, create_skill_link) - .unwrap(); - } - let error = - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", true, create_skill_link) - .unwrap_err(); - assert!(error.to_string().contains("disabled peer")); - assert!(!shared.join("demo").exists()); - assert!(disabled_skill_root(&shared).join("demo/SKILL.md").is_file()); - assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); - } - - #[test] - fn shared_skill_command_claude_cline_roundtrip() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".claude/skills"); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "body").unwrap(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - for enabled in [false, true] { - let item = runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::ClaudeCode, - AgentSkillScope::Project, - "demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - enabled, - )) - .unwrap(); - assert_eq!(item.enabled, enabled); - let cline = scoped_skill_dirs( - AgentType::Cline, - AgentSkillScope::Project, - tmp.path().to_str(), - ) - .unwrap(); - assert!( - list_skills_from_roots( - AgentSkillScope::Project, - &cline, - SkillStorageKind::SkillDirectoryOnly - ) - .unwrap()[0] - .enabled - ); - } - } - - #[cfg(unix)] - #[test] - fn shared_skill_unsearchable_root_is_rejected_before_move() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - fs::create_dir_all(&peers[0].roots[0]).unwrap(); - fs::set_permissions(&peers[0].roots[0], fs::Permissions::from_mode(0o600)).unwrap(); - let result = - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link); - fs::set_permissions(&peers[0].roots[0], fs::Permissions::from_mode(0o700)).unwrap(); - assert!(result - .unwrap_err() - .to_string() - .contains("unique writable root")); - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[test] - fn shared_skill_destination_collision_refuses_before_move() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - fs::create_dir_all(&peers[0].roots[0]).unwrap(); - fs::write(peers[0].roots[0].join("demo"), "occupied").unwrap(); - let error = - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) - .unwrap_err(); - assert!(error.to_string().contains("collision")); - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).exists()); - } - - #[test] - fn shared_skill_disabled_copy_collision_refuses_before_move() { - for conflicting_peer in [0, 1] { - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - let other_vault = disabled_skill_root(&peers[conflicting_peer].roots[0]); - fs::create_dir_all(other_vault.join("demo")).unwrap(); - fs::write( - other_vault.join("demo/SKILL.md"), - "different disabled skill", - ) - .unwrap(); - let error = set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "demo", - false, - create_skill_link, - ) - .unwrap_err(); - assert!(error.to_string().contains("collision")); - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).exists()); - } - } - - #[test] - fn shared_skill_partial_link_failure_rolls_back_link_and_source() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - let error = set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "demo", - false, - |source, target| { - create_skill_link(source, target)?; - Err(std::io::Error::other("failure after creating link")) - }, - ) - .unwrap_err(); - assert!(error.to_string().contains("failure after creating link")); - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); - } - - #[test] - fn shared_skill_link_failure_rolls_back_source_and_created_links() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - peers.push(SkillPeer { - agent: AgentType::OpenCode, - roots: vec![tmp.path().join("c/skills"), shared.clone()], - ..peers[0].clone() - }); - let mut calls = 0; - let error = set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "demo", - false, - |source, target| { - calls += 1; - if calls == 2 { - return Err(std::io::Error::other("injected link failure")); - } - create_skill_link(source, target) - }, - ) - .unwrap_err(); - assert!(error.to_string().contains("injected link failure")); - assert_eq!(calls, 2); - assert!(shared.join("demo/SKILL.md").is_file()); - assert!(!disabled_skill_root(&shared).join("demo").exists()); - for peer in peers { - assert!(fs::symlink_metadata(peer.roots[0].join("demo")).is_err()); - } - } - - #[test] - fn shared_skill_markdown_file_keeps_canonical_content() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - fs::write(shared.join("flat.md"), "flat content").unwrap(); - for enabled in [false, true] { - set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "flat", - enabled, - create_skill_link, - ) - .unwrap(); - assert_eq!( - fs::read_to_string(peers[0].roots[0].join("flat.md")).unwrap(), - "flat content" - ); - assert_eq!(peers[1].roots[0].join("flat.md").exists(), enabled); - } - } - - #[test] - fn shared_skill_delete_canonical_removes_peer_links() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, peers) = shared_skill_fixture(tmp.path()); - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) - .unwrap(); - let canonical = disabled_skill_root(&shared).join("demo"); - delete_shared_skill(&canonical, &peers, "demo", |from, to| fs::rename(from, to)).unwrap(); - assert!(!canonical.exists()); - assert!(fs::symlink_metadata(peers[0].roots[0].join("demo")).is_err()); - for peer in &peers { - assert!(list_skills_from_roots(peer.scope, &peer.roots, peer.kind) - .unwrap() - .is_empty()); - } - } - - #[test] - fn shared_skill_delete_rename_failure_rolls_back_canonical_and_links() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - peers.push(SkillPeer { - agent: AgentType::OpenCode, - roots: vec![tmp.path().join("c/skills"), shared.clone()], - ..peers[0].clone() - }); - set_shared_skill_enabled(&peers[1], &peers, &shared, "demo", false, create_skill_link) - .unwrap(); - let canonical = disabled_skill_root(&shared).join("demo"); - let mut calls = 0; - let result = delete_shared_skill(&canonical, &peers, "demo", |from, to| { - calls += 1; - if calls == 3 { - return Err(std::io::Error::other("injected delete failure")); - } - fs::rename(from, to) - }); - assert!(result - .unwrap_err() - .to_string() - .contains("injected delete failure")); - assert_eq!(calls, 3); - assert!(canonical.join("SKILL.md").is_file()); - for peer in [&peers[0], &peers[2]] { - assert!(peer.roots[0].join("demo/SKILL.md").is_file()); - } - } + // `pathExists` accepts — so must this. + std::fs::write(repo.join(".git"), "gitdir: /elsewhere\n").expect("write .git file"); - #[cfg(unix)] - #[test] - fn shared_skill_delete_keeps_independent_links_to_external_content() { - let tmp = tempfile::tempdir().unwrap(); - let (shared, mut peers) = shared_skill_fixture(tmp.path()); - let external = tmp.path().join("external"); - fs::create_dir_all(&external).unwrap(); - fs::write(external.join("SKILL.md"), "external").unwrap(); - std::os::unix::fs::symlink(&external, shared.join("linked")).unwrap(); - set_shared_skill_enabled( - &peers[1], - &peers, - &shared, - "linked", - false, - create_skill_link, + let dirs = scoped_skill_dirs( + AgentType::DeepSeek, + AgentSkillScope::Project, + Some(nested.to_str().expect("utf-8 path")), ) - .unwrap(); - let independent = tmp.path().join("independent/skills"); - fs::create_dir_all(&independent).unwrap(); - std::os::unix::fs::symlink(&external, independent.join("linked")).unwrap(); - peers.push(SkillPeer { - agent: AgentType::OpenCode, - roots: vec![independent.clone()], - ..peers[0].clone() - }); - delete_shared_skill( - &disabled_skill_root(&shared).join("linked"), - &peers, - "linked", - |from, to| fs::rename(from, to), + .expect("project dirs"); + assert_eq!( + dirs, + vec![repo.join(".dsh/skills"), repo.join(".agents/skills")] + ); + + // No `.git` anywhere above ⇒ fall back to the workspace itself. + let bare = tmp.path().join("bare"); + std::fs::create_dir_all(&bare).expect("create bare"); + let fallback = scoped_skill_dirs( + AgentType::DeepSeek, + AgentSkillScope::Project, + Some(bare.to_str().expect("utf-8 path")), ) - .unwrap(); - assert!(independent.join("linked/SKILL.md").is_file()); - assert!(external.join("SKILL.md").is_file()); - assert!(fs::symlink_metadata(peers[0].roots[0].join("linked")).is_err()); - } + .expect("fallback dirs"); + assert_eq!(fallback[0], bare.join(".dsh/skills")); - #[test] - fn shared_skill_command_delete_canonical_removes_cline_link() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".claude/skills"); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "body").unwrap(); - let workspace = Some(tmp.path().to_string_lossy().into_owned()); - let runtime = tokio::runtime::Runtime::new().unwrap(); - runtime - .block_on(acp_set_agent_skill_enabled( - AgentType::ClaudeCode, - AgentSkillScope::Project, - "demo".into(), - workspace.clone(), - false, - )) - .unwrap(); - let link = tmp.path().join(".cline/skills/demo"); - assert!(link.join("SKILL.md").is_file()); - runtime - .block_on(acp_delete_agent_skill( - AgentType::ClaudeCode, - AgentSkillScope::Project, - "demo".into(), - workspace, + // Every other agent keeps the plain workspace-relative layout. + let codex = scoped_skill_dirs( + AgentType::Codex, + AgentSkillScope::Project, + Some(nested.to_str().expect("utf-8 path")), + ) + .expect("codex dirs"); + assert_eq!(codex[0], nested.join(".codex/skills")); + + // ...and the LIST path must resolve the same base as the WRITE path: + // a skill saved from the nested workspace lands at the repo root, so + // listing the workspace directly would show it as missing. + let saved = repo.join(".dsh/skills").join("demo"); + std::fs::create_dir_all(&saved).expect("create skill dir"); + std::fs::write(saved.join("SKILL.md"), "---\nname: demo\n---\nbody\n") + .expect("write SKILL.md"); + let listed = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_list_agent_skills( + AgentType::DeepSeek, + Some(nested.to_string_lossy().to_string()), )) - .unwrap(); - assert!(fs::symlink_metadata(link).is_err()); - assert!(!disabled_skill_root(&root).join("demo").exists()); + .expect("list skills"); + assert!( + listed.skills.iter().any(|s| s.id == "demo"), + "skill saved at the git root must be listed from a nested workspace: {:?}", + listed.skills + ); + assert!( + listed + .locations + .iter() + .any(|l| l.path == repo.join(".dsh/skills").to_string_lossy()), + "the listed project location must be the git root: {:?}", + listed.locations + ); } #[test] @@ -19144,33 +18235,391 @@ wire_api = "chat" .skill_enabled(&path, "demo") ); - let updated_again = apply_codex_skill_enabled_config( - &updated, - Path::new("/tmp/codex-home"), - "demo", - std::slice::from_ref(&path), - false, - ) - .unwrap(); - let parsed = updated_again.parse::().unwrap(); - let entries = parsed["skills"]["config"].as_array().unwrap(); - assert_eq!(entries.len(), 3, "repeated toggles reuse the path override"); - assert!( - !parse_codex_skill_config(&updated_again, Path::new("/tmp/codex-home")) - .unwrap() - .skill_enabled(&path, "demo") - ); + let updated_again = apply_codex_skill_enabled_config( + &updated, + Path::new("/tmp/codex-home"), + "demo", + std::slice::from_ref(&path), + false, + ) + .unwrap(); + let parsed = updated_again.parse::().unwrap(); + let entries = parsed["skills"]["config"].as_array().unwrap(); + assert_eq!(entries.len(), 3, "repeated toggles reuse the path override"); + assert!( + !parse_codex_skill_config(&updated_again, Path::new("/tmp/codex-home")) + .unwrap() + .skill_enabled(&path, "demo") + ); + } + + #[test] + fn codex_skill_config_selectorless_entry_matches_no_skill() { + let config = parse_codex_skill_config( + "[[skills.config]]\nenabled = false\n", + Path::new("/tmp/codex-home"), + ) + .expect("Codex accepts selectorless entries"); + + assert!(config.skill_enabled(Path::new("/tmp/demo/SKILL.md"), "demo")); + } + + #[test] + fn codex_skill_config_mixed_selector_matches_no_skill_and_is_preserved() { + let path = PathBuf::from("/tmp/demo/SKILL.md"); + let base = "# keep mixed selector\n[[skills.config]]\npath = \"/tmp/demo/SKILL.md\"\nname = \"demo\"\nenabled = false\n"; + let config = parse_codex_skill_config(base, Path::new("/tmp/codex-home")) + .expect("mixed selector remains syntactically valid TOML"); + assert!( + config.skill_enabled(&path, "demo"), + "Codex only applies selectors with exactly one of path or name" + ); + + let updated = apply_codex_skill_enabled_config( + base, + Path::new("/tmp/codex-home"), + "demo", + std::slice::from_ref(&path), + false, + ) + .expect("mixed selector must not prevent a precise path override"); + let parsed = updated + .parse::() + .expect("updated TOML is valid"); + let entries = parsed["skills"]["config"].as_array().expect("config array"); + assert_eq!(entries.len(), 2); + assert_eq!( + entries[0].get("name").and_then(toml::Value::as_str), + Some("demo") + ); + assert_eq!( + entries[0].get("path").and_then(toml::Value::as_str), + path.to_str() + ); + assert_eq!( + entries[0].get("enabled").and_then(toml::Value::as_bool), + Some(false) + ); + assert_eq!( + entries[1].get("path").and_then(toml::Value::as_str), + path.to_str() + ); + assert!(entries[1].get("name").is_none()); + assert_eq!( + entries[1].get("enabled").and_then(toml::Value::as_bool), + Some(false) + ); + } + + #[test] + fn codex_skill_config_updates_root_inline_skills_table() { + let path = PathBuf::from("/tmp/demo/SKILL.md"); + let updated = apply_codex_skill_enabled_config( + "# root inline table\nskills = { config = [{ name = \"demo\", enabled = true }], keep = \"value\" }\n", + Path::new("/tmp/codex-home"), + "demo", + std::slice::from_ref(&path), + false, + ) + .expect("root inline skills table remains writable"); + + assert!(updated.contains("skills = {")); + let parsed = updated + .parse::() + .expect("updated TOML is valid"); + assert_eq!( + parsed["skills"].get("keep").and_then(toml::Value::as_str), + Some("value") + ); + let entries = parsed["skills"]["config"].as_array().expect("config array"); + assert_eq!(entries.len(), 2); + assert_eq!( + entries[0].get("name").and_then(toml::Value::as_str), + Some("demo") + ); + assert_eq!( + entries[0].get("enabled").and_then(toml::Value::as_bool), + Some(true), + "path-only write must not rewrite a name selector" + ); + assert_eq!( + entries[1].get("path").and_then(toml::Value::as_str), + path.to_str() + ); + assert!(entries[1].get("name").is_none()); + assert_eq!( + entries[1].get("enabled").and_then(toml::Value::as_bool), + Some(false) + ); + } + + #[test] + fn codex_skill_config_uses_frontmatter_name_for_name_selector() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let workspace = tmp.path().join("workspace"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + fs::create_dir_all(&codex_home).expect("create Codex home"); + fs::write( + codex_home.join("config.toml"), + "[[skills.config]]\nname = \"Visible Demo\"\nenabled = false\n", + ) + .expect("write config"); + let skill = workspace.join(".codex/skills/file-id"); + fs::create_dir_all(&skill).expect("create skill"); + fs::write( + skill.join("SKILL.md"), + "---\nname: \"Visible Demo\"\ndescription: Demo\n---\n", + ) + .expect("write skill"); + + let listed = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_list_agent_skills( + AgentType::Codex, + Some(workspace.to_string_lossy().into_owned()), + )) + .expect("list skills"); + let item = listed + .skills + .iter() + .find(|item| item.id == "file-id") + .expect("skill listed"); + assert_eq!(item.name, "Visible Demo"); + assert!(!item.enabled); + }); + } + + #[test] + fn codex_lists_only_the_newest_enabled_plugin_skills_with_namespaced_frontmatter_names() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + fs::create_dir_all(&codex_home).expect("create Codex home"); + fs::write( + codex_home.join("config.toml"), + "[plugins.\"enabled-plugin@demo-market\"]\nenabled = true\n\n[plugins.\"disabled-plugin@demo-market\"]\nenabled = false\n", + ) + .expect("write config"); + + for (plugin, version, frontmatter_name) in [ + ("enabled-plugin", "1.0.0", "from-old-version"), + ("enabled-plugin", "2.0.0", "from-newest-version"), + ("disabled-plugin", "3.0.0", "must-stay-hidden"), + ] { + let plugin_root = codex_home + .join("plugins/cache/demo-market") + .join(plugin) + .join(version); + fs::create_dir_all(plugin_root.join(".codex-plugin")) + .expect("create plugin manifest directory"); + fs::create_dir_all(plugin_root.join("plugin-skills/namespaced")) + .expect("create plugin skills directory"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!( + "{{\"name\":\"{plugin}\",\"version\":\"{version}\",\"skills\":\"plugin-skills\"}}" + ), + ) + .expect("write plugin manifest"); + fs::write( + plugin_root.join("plugin-skills/namespaced/SKILL.md"), + format!("---\nname: {frontmatter_name}\ndescription: plugin skill\n---\n"), + ) + .expect("write plugin skill"); + } + + let listed = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_list_agent_skills(AgentType::Codex, None)) + .expect("list skills"); + let skill = listed + .skills + .iter() + .find(|item| item.id == "enabled-plugin:from-newest-version") + .expect("newest enabled plugin skill is listed"); + assert_eq!(skill.name, "enabled-plugin:from-newest-version"); + assert_eq!(skill.scope, AgentSkillScope::Global); + assert!(skill.enabled); + assert!(skill.read_only); + assert!(skill.can_toggle); + assert!(Path::new(&skill.path).ends_with( + Path::new("enabled-plugin") + .join("2.0.0") + .join("plugin-skills") + .join("namespaced"), + )); + assert!(!listed + .skills + .iter() + .any(|item| item.id.contains("from-old-version") + || item.id.contains("must-stay-hidden"))); + }); + } + + #[test] + fn codex_plugin_skills_are_readable_toggleable_but_not_mutable_and_unknown_namespaced_ids_are_rejected( + ) { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let plugin_root = codex_home.join("plugins/cache/demo-market/protected-plugin/1.0.0"); + fs::create_dir_all(plugin_root.join(".codex-plugin")) + .expect("create plugin manifest directory"); + fs::create_dir_all(plugin_root.join("plugin-skills/protected")) + .expect("create plugin skills directory"); + fs::write( + codex_home.join("config.toml"), + "# preserve this comment\nmodel = \"keep-me\"\n\n[plugins.\"protected-plugin@demo-market\"]\nenabled = true\n", + ) + .expect("write config"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + "{\"name\":\"protected-plugin\",\"version\":\"1.0.0\",\"skills\":\"plugin-skills\"}", + ) + .expect("write plugin manifest"); + let content_path = plugin_root.join("plugin-skills/protected/SKILL.md"); + fs::write( + &content_path, + "---\nname: protected-skill\ndescription: protected plugin skill\n---\nbody\n", + ) + .expect("write plugin skill"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let plugin_id = "protected-plugin:protected-skill".to_string(); + + let read = runtime + .block_on(acp_read_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + plugin_id.clone(), + None, + )) + .expect("plugin skill remains readable"); + assert_eq!(read.content, fs::read_to_string(&content_path).unwrap()); + assert!(read.skill.read_only); + + let disabled = runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Global, + plugin_id.clone(), + None, + false, + )) + .expect("plugin skill toggle uses native configuration"); + assert!(!disabled.enabled); + assert!(disabled.read_only); + assert!(content_path.is_file()); + let config = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + assert!(config.contains("# preserve this comment")); + assert!(config.contains("model = \"keep-me\"")); + let parsed = config.parse::().expect("valid config"); + let rules = parsed["skills"]["config"].as_array().expect("skill rules"); + assert_eq!(rules.len(), 1); + assert!(rules[0].get("name").is_none()); + assert_eq!( + rules[0].get("path").and_then(toml::Value::as_str), + content_path.canonicalize().unwrap().to_str() + ); + assert_eq!( + rules[0].get("enabled").and_then(toml::Value::as_bool), + Some(false) + ); + + let save = runtime.block_on(acp_save_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + plugin_id.clone(), + "replacement".into(), + None, + None, + )); + assert!(save.is_err(), "plugin skills cannot be saved: {save:?}"); + assert!(fs::read_to_string(&content_path).unwrap().contains("body")); + let delete = runtime.block_on(acp_delete_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + plugin_id, + None, + )); + assert!( + delete.is_err(), + "plugin skills cannot be deleted: {delete:?}" + ); + assert!(content_path.is_file()); + + let unknown_id = "unknown-plugin:unknown-skill".to_string(); + assert!(runtime + .block_on(acp_read_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + unknown_id.clone(), + None, + )) + .is_err()); + assert!(runtime + .block_on(acp_set_agent_skill_enabled( + AgentType::Codex, + AgentSkillScope::Global, + unknown_id.clone(), + None, + false, + )) + .is_err()); + assert!(runtime + .block_on(acp_delete_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + unknown_id.clone(), + None, + )) + .is_err()); + assert!(runtime + .block_on(acp_save_agent_skill( + AgentType::Codex, + AgentSkillScope::Global, + unknown_id, + "must not be written".into(), + None, + None, + )) + .is_err()); + assert!(!codex_home + .join("skills") + .join("unknown-plugin:unknown-skill.md") + .exists()); + }); } #[test] - fn codex_skill_config_selectorless_entry_matches_no_skill() { - let config = parse_codex_skill_config( - "[[skills.config]]\nenabled = false\n", - Path::new("/tmp/codex-home"), - ) - .expect("Codex accepts selectorless entries"); + fn codex_bundled_config_disabled_overlays_system_skill_state() { + let tmp = tempfile::tempdir().expect("tempdir"); + temp_env::with_var("CODEX_HOME", Some(tmp.path()), || { + fs::write( + tmp.path().join("config.toml"), + "[skills.bundled]\nenabled = false\n", + ) + .expect("write bundled setting"); + let system_skill = tmp.path().join("skills/.system/bundled-demo"); + fs::create_dir_all(&system_skill).expect("create system skill"); + fs::write(system_skill.join("SKILL.md"), "system\n").expect("write system skill"); - assert!(config.skill_enabled(Path::new("/tmp/demo/SKILL.md"), "demo")); + let listed = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_list_agent_skills(AgentType::Codex, None)) + .expect("list skills"); + let item = listed + .skills + .iter() + .find(|item| item.id == "bundled-demo") + .expect("system skill listed"); + assert!(!item.enabled); + assert!(!item.can_toggle); + assert_eq!( + item.toggle_reason, + Some(AgentSkillToggleReason::BundledDisabled) + ); + }); } #[test] @@ -19373,128 +18822,524 @@ wire_api = "chat" } #[test] - fn skill_enabled_private_safety_postcondition_mismatch_rolls_back() { + #[cfg(unix)] + fn skill_enabled_private_safety_vault_alias_to_native_root_is_rejected() { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("private/skills"); - let other = tmp.path().join("other/skills"); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let root = workspace.join(".grok/skills"); + let other = workspace.join(".agents/skills"); fs::create_dir_all(root.join("demo")).unwrap(); fs::write(root.join("demo/SKILL.md"), "original").unwrap(); - let original = locate_existing_skill( + fs::create_dir_all(&other).unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let vault = private_skill_vault( + AgentType::Grok, + AgentSkillScope::Project, + Some(&workspace_path), + &data_dir, &root, - SkillStorageKind::SkillDirectoryOnly, - "demo", + ) + .unwrap(); + fs::create_dir_all(vault.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&other, &vault).unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + assert_eq!( + skill.toggle_reason, + Some(AgentSkillToggleReason::StorageConflict) + ); + + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, AgentSkillScope::Project, - true, + "demo".into(), + Some(workspace_path), + false, + &data_dir, + )); + assert!( + result.is_err(), + "vault must not alias any native scan root: {result:?}" + ); + assert_eq!( + fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), + "original" + ); + assert!(!other.join("demo").exists()); + assert_eq!(fs::read_link(&vault).unwrap(), other); + } + + #[test] + #[cfg(unix)] + fn private_skill_incoming_directory_alias_is_unsafe_and_never_moved() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let owner_root = workspace.join(".grok/skills"); + let peer_root = workspace.join(".cursor/skills"); + fs::create_dir_all(owner_root.join("demo")).unwrap(); + fs::write(owner_root.join("demo/SKILL.md"), "private").unwrap(); + fs::create_dir_all(&peer_root).unwrap(); + std::os::unix::fs::symlink(owner_root.join("demo"), peer_root.join("alias")).unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path.clone()), + false, + &data_dir, + )); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + + assert!(!skill.can_toggle); + assert_eq!( + skill.toggle_reason, + Some(AgentSkillToggleReason::UnsafeLink) + ); + assert!(result.is_err(), "incoming alias must block the move"); + assert_eq!( + fs::read_to_string(owner_root.join("demo/SKILL.md")).unwrap(), + "private" + ); + assert_eq!( + fs::read_to_string(peer_root.join("alias/SKILL.md")).unwrap(), + "private" + ); + let vault = private_skill_vault( + AgentType::Grok, + AgentSkillScope::Project, + Some(&workspace_path), + &data_dir, + &owner_root, ) .unwrap(); - let moved = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, + assert!(!vault.join("demo").exists()); + } + + #[test] + #[cfg(unix)] + fn private_skill_indirect_incoming_directory_alias_is_unsafe_and_never_moved() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let owner_root = workspace.join(".grok/skills"); + let peer_root = workspace.join(".cursor/skills"); + let external_alias = workspace.join("external-alias"); + fs::create_dir_all(owner_root.join("demo")).unwrap(); + fs::write(owner_root.join("demo/SKILL.md"), "private").unwrap(); + fs::create_dir_all(&peer_root).unwrap(); + std::os::unix::fs::symlink(owner_root.join("demo"), &external_alias).unwrap(); + std::os::unix::fs::symlink(&external_alias, peer_root.join("alias")).unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, AgentSkillScope::Project, - "demo", + "demo".into(), + Some(workspace_path.clone()), + false, + &data_dir, + )); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + + assert!(!skill.can_toggle); + assert_eq!( + skill.toggle_reason, + Some(AgentSkillToggleReason::UnsafeLink) + ); + assert!(result.is_err(), "indirect incoming alias must block the move"); + assert_eq!( + fs::read_to_string(owner_root.join("demo/SKILL.md")).unwrap(), + "private" + ); + assert_eq!( + fs::read_to_string(peer_root.join("alias/SKILL.md")).unwrap(), + "private" + ); + let vault = private_skill_vault( + AgentType::Grok, + AgentSkillScope::Project, + Some(&workspace_path), + &data_dir, + &owner_root, + ) + .unwrap(); + assert!(!vault.join("demo").exists()); + } + + #[test] + #[cfg(unix)] + fn private_skill_incoming_content_link_is_unsafe_and_never_moved() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let owner_root = workspace.join(".grok/skills"); + let peer_root = workspace.join(".cursor/skills"); + fs::create_dir_all(owner_root.join("demo")).unwrap(); + fs::write(owner_root.join("demo/SKILL.md"), "private").unwrap(); + fs::create_dir_all(peer_root.join("alias")).unwrap(); + std::os::unix::fs::symlink( + owner_root.join("demo/SKILL.md"), + peer_root.join("alias/SKILL.md"), + ) + .unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path), + false, + &data_dir, + )); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + + assert!(!skill.can_toggle); + assert_eq!( + skill.toggle_reason, + Some(AgentSkillToggleReason::UnsafeLink) + ); + assert!(result.is_err(), "incoming content link must block the move"); + assert_eq!( + fs::read_to_string(owner_root.join("demo/SKILL.md")).unwrap(), + "private" + ); + assert_eq!( + fs::read_to_string(peer_root.join("alias/SKILL.md")).unwrap(), + "private" + ); + } + + #[test] + #[cfg(unix)] + fn private_symlinked_skill_incoming_content_link_is_unsafe_and_never_moved() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let owner_root = workspace.join(".grok/skills"); + let peer_root = workspace.join(".cursor/skills"); + let external_skill = workspace.join("external/demo"); + fs::create_dir_all(&external_skill).unwrap(); + fs::write(external_skill.join("SKILL.md"), "private").unwrap(); + fs::create_dir_all(&owner_root).unwrap(); + std::os::unix::fs::symlink(&external_skill, owner_root.join("demo")).unwrap(); + fs::create_dir_all(peer_root.join("alias")).unwrap(); + std::os::unix::fs::symlink( + owner_root.join("demo/SKILL.md"), + peer_root.join("alias/SKILL.md"), + ) + .unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path), + false, + &data_dir, + )); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + + assert!(!skill.can_toggle); + assert_eq!( + skill.toggle_reason, + Some(AgentSkillToggleReason::UnsafeLink) + ); + assert!( + result.is_err(), + "incoming content link through a symlinked skill must block the move" + ); + assert!(owner_root.join("demo").exists()); + assert_eq!( + fs::read_to_string(peer_root.join("alias/SKILL.md")).unwrap(), + "private" + ); + } + + #[test] + #[cfg(unix)] + fn private_symlinked_skill_with_internal_content_link_remains_toggleable() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let owner_root = workspace.join(".grok/skills"); + let external_skill = workspace.join("external/demo"); + fs::create_dir_all(external_skill.join("docs")).unwrap(); + fs::write(external_skill.join("docs/body.md"), "private").unwrap(); + std::os::unix::fs::symlink("docs/body.md", external_skill.join("SKILL.md")).unwrap(); + fs::create_dir_all(&owner_root).unwrap(); + std::os::unix::fs::symlink(&external_skill, owner_root.join("demo")).unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let before = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + let disabled = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path.clone()), false, + &data_dir, + )); + + assert!(before.can_toggle); + assert_eq!(before.toggle_reason, None); + let disabled = disabled.expect("safe symlinked skill should disable"); + assert!(!disabled.enabled); + assert_eq!( + fs::read_to_string(Path::new(&disabled.path).join("SKILL.md")).unwrap(), + "private" + ); + assert!(!owner_root.join("demo").exists()); + + let enabled = runtime + .block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path), + true, + &data_dir, + )) + .expect("safe symlinked skill should re-enable"); + assert!(enabled.enabled); + assert_eq!(fs::read_link(owner_root.join("demo")).unwrap(), external_skill); + assert_eq!( + fs::read_link(owner_root.join("demo/SKILL.md")).unwrap(), + Path::new("docs/body.md") + ); + } + + #[test] + #[cfg(unix)] + fn private_symlinked_skill_with_absolute_backreference_is_unsafe() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let owner_root = workspace.join(".grok/skills"); + let external_skill = workspace.join("external/demo"); + fs::create_dir_all(external_skill.join("docs")).unwrap(); + fs::write(external_skill.join("docs/body.md"), "private").unwrap(); + fs::create_dir_all(&owner_root).unwrap(); + std::os::unix::fs::symlink(&external_skill, owner_root.join("demo")).unwrap(); + std::os::unix::fs::symlink( + owner_root.join("demo/docs/body.md"), + external_skill.join("SKILL.md"), ) .unwrap(); - fs::create_dir_all(other.join("demo")).unwrap(); - fs::write(other.join("demo/SKILL.md"), "concurrent copy").unwrap(); - let result = finish_private_skill_toggle( - AgentType::Codex, - &[root.clone(), other.clone()], - SkillStorageKind::SkillDirectoryOnly, - &original, - moved, + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path), false, - ); - assert!( - result.is_err(), - "requested state must be a postcondition: {result:?}" - ); + &data_dir, + )); + + assert!(!skill.can_toggle); assert_eq!( - fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "original" + skill.toggle_reason, + Some(AgentSkillToggleReason::UnsafeLink) ); + assert!(result.is_err(), "absolute backreference must block the move"); assert_eq!( - fs::read_to_string(other.join("demo/SKILL.md")).unwrap(), - "concurrent copy" + fs::read_to_string(owner_root.join("demo/SKILL.md")).unwrap(), + "private" ); - assert!(!disabled_skill_root(&root).join("demo").exists()); + assert_eq!(fs::read_link(owner_root.join("demo")).unwrap(), external_skill); + assert!(!data_dir.exists()); } #[test] - fn skill_enabled_private_safety_missing_post_move_entry_rolls_back() { + #[cfg(unix)] + fn private_markdown_skill_incoming_alias_is_unsafe_and_never_moved() { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("skills"); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "original").unwrap(); - let original = locate_existing_skill( - &root, - SkillStorageKind::SkillDirectoryOnly, - "demo", - AgentSkillScope::Project, - true, - ) - .unwrap(); - let moved = set_private_skill_enabled( - &root, - SkillStorageKind::SkillDirectoryOnly, + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let owner_root = workspace.join(".dsh/skills"); + let peer_root = workspace.join(".pi/skills"); + fs::create_dir_all(&owner_root).unwrap(); + fs::write(owner_root.join("demo.md"), "private markdown").unwrap(); + fs::create_dir_all(&peer_root).unwrap(); + std::os::unix::fs::symlink(owner_root.join("demo.md"), peer_root.join("alias.md")).unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::DeepSeek, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::DeepSeek, AgentSkillScope::Project, - "demo", - false, - ) - .unwrap(); - fs::rename( - Path::new(&moved.path).join("SKILL.md"), - Path::new(&moved.path).join("body.saved"), - ) - .unwrap(); - let result = finish_private_skill_toggle( - AgentType::Codex, - std::slice::from_ref(&root), - SkillStorageKind::SkillDirectoryOnly, - &original, - moved, + "demo".into(), + Some(workspace_path), false, + &data_dir, + )); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + + assert!(!skill.can_toggle); + assert_eq!( + skill.toggle_reason, + Some(AgentSkillToggleReason::UnsafeLink) ); - assert!(result.is_err()); + assert!(result.is_err(), "incoming markdown alias must block the move"); assert_eq!( - fs::read_to_string(root.join("demo/body.saved")).unwrap(), - "original" + fs::read_to_string(owner_root.join("demo.md")).unwrap(), + "private markdown" + ); + assert_eq!( + fs::read_to_string(peer_root.join("alias.md")).unwrap(), + "private markdown" ); - assert!(!disabled_skill_root(&root).join("demo").exists()); } #[test] - #[cfg(unix)] - fn skill_enabled_private_safety_vault_alias_to_native_root_is_rejected() { - for destination in [".agents/skills", "skills"] { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".gemini/skills"); - let other = tmp.path().join(destination); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), "original").unwrap(); - fs::create_dir_all(&other).unwrap(); - std::os::unix::fs::symlink(&other, disabled_skill_root(&root)).unwrap(); - let result = - tokio::runtime::Runtime::new() - .unwrap() - .block_on(acp_set_agent_skill_enabled( - AgentType::Gemini, - AgentSkillScope::Project, - "demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - false, - )); - assert!( - result.is_err(), - "vault must not alias any native scan root: {result:?}" - ); - assert_eq!( - fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), - "original" - ); - assert!(!other.join("demo").exists()); - assert_eq!(fs::read_link(disabled_skill_root(&root)).unwrap(), other); - } + #[cfg(windows)] + fn private_skill_junction_is_unsafe_and_never_moved() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let target = tmp.path().join("junction-target"); + let root = workspace.join(".grok/skills"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("SKILL.md"), "junction content").unwrap(); + fs::create_dir_all(&root).unwrap(); + junction::create(&target, root.join("demo")).unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let skill = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + assert_eq!( + skill.toggle_reason, + Some(AgentSkillToggleReason::UnsafeLink) + ); + + let result = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path), + false, + &data_dir, + )); + assert!(result.is_err(), "junction must be rejected: {result:?}"); + assert!(root.join("demo/SKILL.md").is_file()); + assert_eq!( + fs::read_to_string(target.join("SKILL.md")).unwrap(), + "junction content" + ); + assert!(!data_dir.exists()); } #[test] @@ -19502,11 +19347,13 @@ wire_api = "chat" fn skill_enabled_private_safety_bundle_content_link_escape_is_rejected() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("skills"); + let vault = tmp.path().join("vault"); fs::create_dir_all(root.join("demo")).unwrap(); fs::write(root.join("body.txt"), "original content").unwrap(); std::os::unix::fs::symlink("../body.txt", root.join("demo/SKILL.md")).unwrap(); - let result = set_private_skill_enabled( + let result = set_private_skill_enabled_at( &root, + &vault, SkillStorageKind::SkillDirectoryOnly, AgentSkillScope::Global, "demo", @@ -19520,7 +19367,7 @@ wire_api = "chat" fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), "original content" ); - assert!(!disabled_skill_root(&root).exists()); + assert!(!vault.exists()); } #[test] @@ -19528,12 +19375,14 @@ wire_api = "chat" fn skill_enabled_private_safety_bundle_nested_asset_escape_is_rejected() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("skills"); + let vault = tmp.path().join("vault"); fs::create_dir_all(root.join("demo/assets")).unwrap(); fs::write(root.join("demo/SKILL.md"), "body").unwrap(); fs::write(root.join("asset.txt"), "original asset").unwrap(); std::os::unix::fs::symlink("../../asset.txt", root.join("demo/assets/link")).unwrap(); - let result = set_private_skill_enabled( + let result = set_private_skill_enabled_at( &root, + &vault, SkillStorageKind::SkillDirectoryOnly, AgentSkillScope::Global, "demo", @@ -19547,7 +19396,7 @@ wire_api = "chat" fs::read_to_string(root.join("demo/assets/link")).unwrap(), "original asset" ); - assert!(!disabled_skill_root(&root).exists()); + assert!(!vault.exists()); } #[test] @@ -19555,14 +19404,16 @@ wire_api = "chat" fn skill_enabled_private_safety_internal_bundle_links_round_trip() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("skills"); + let vault = tmp.path().join("vault"); fs::create_dir_all(root.join("demo/docs")).unwrap(); fs::create_dir_all(root.join("demo/assets")).unwrap(); fs::write(root.join("demo/docs/body.md"), "internal content").unwrap(); std::os::unix::fs::symlink("docs/body.md", root.join("demo/SKILL.md")).unwrap(); std::os::unix::fs::symlink("../docs/body.md", root.join("demo/assets/link")).unwrap(); for enabled in [false, true] { - let item = set_private_skill_enabled( + let item = set_private_skill_enabled_at( &root, + &vault, SkillStorageKind::SkillDirectoryOnly, AgentSkillScope::Global, "demo", @@ -19585,6 +19436,81 @@ wire_api = "chat" } } + #[test] + #[cfg(unix)] + fn project_private_skill_internal_links_round_trip_through_fresh_vault() { + let tmp = tempfile::tempdir().unwrap(); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("fresh-codeg-data"); + let root = workspace.join(".grok/skills"); + let external = tmp.path().join("external.txt"); + fs::create_dir_all(root.join("demo/docs")).unwrap(); + fs::create_dir_all(root.join("demo/assets")).unwrap(); + fs::write(root.join("demo/docs/body.md"), "internal content").unwrap(); + fs::write(&external, "external content").unwrap(); + std::os::unix::fs::symlink("docs/body.md", root.join("demo/SKILL.md")).unwrap(); + std::os::unix::fs::symlink(&external, root.join("demo/assets/external")).unwrap(); + let workspace_path = workspace.to_string_lossy().into_owned(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let listed = runtime + .block_on(acp_list_agent_skills_core( + AgentType::Grok, + Some(workspace_path.clone()), + &data_dir, + )) + .unwrap(); + let before = listed + .skills + .iter() + .find(|skill| skill.id == "demo") + .unwrap(); + let disabled = runtime.block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path.clone()), + false, + &data_dir, + )); + + assert!(before.can_toggle, "fresh vault ancestors are created by the move"); + assert_eq!(before.toggle_reason, None); + let disabled = disabled.expect("safe linked bundle should disable"); + assert!(!disabled.enabled); + let disabled_path = Path::new(&disabled.path); + assert!(disabled_path.starts_with(&data_dir)); + assert_eq!( + fs::read_to_string(disabled_path.join("SKILL.md")).unwrap(), + "internal content" + ); + assert_eq!( + fs::read_to_string(disabled_path.join("assets/external")).unwrap(), + "external content" + ); + + let enabled = runtime + .block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace_path), + true, + &data_dir, + )) + .expect("safe linked bundle should re-enable"); + assert!(enabled.enabled); + assert_eq!(Path::new(&enabled.path), root.join("demo")); + assert_eq!( + fs::read_link(root.join("demo/SKILL.md")).unwrap(), + Path::new("docs/body.md") + ); + assert_eq!( + fs::read_link(root.join("demo/assets/external")).unwrap(), + external + ); + } + #[test] fn skill_enabled_private_safety_project_root_shared_with_custom_global_is_rejected() { use crate::acp::custom_registry::{ @@ -19593,7 +19519,9 @@ wire_api = "chat" }; let _registry_guard = hydrate_test_guard(); let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".gemini/skills"); + let workspace = tmp.path().join("workspace"); + let data_dir = tmp.path().join("codeg-data"); + let root = workspace.join(".grok/skills"); fs::create_dir_all(root.join("demo")).unwrap(); fs::write(root.join("demo/SKILL.md"), "shared").unwrap(); let definition = CustomAgentDef { @@ -19617,15 +19545,17 @@ wire_api = "chat" supports_mcp: true, }; assert!(hydrate(&[definition]).is_empty()); - let result = tokio::runtime::Runtime::new() - .unwrap() - .block_on(acp_set_agent_skill_enabled( - AgentType::Gemini, - AgentSkillScope::Project, - "demo".into(), - Some(tmp.path().to_string_lossy().into_owned()), - false, - )); + let result = + tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_set_agent_skill_enabled_core( + AgentType::Grok, + AgentSkillScope::Project, + "demo".into(), + Some(workspace.to_string_lossy().into_owned()), + false, + &data_dir, + )); hydrate(&[]); assert!( result.is_err(), @@ -19639,162 +19569,7 @@ wire_api = "chat" fs::read_to_string(root.join("demo/SKILL.md")).unwrap(), "shared" ); - assert!(!disabled_skill_root(&root).exists()); - } - - #[test] - fn skill_enabled_private_safety_sibling_custom_roots_cannot_share_vault() { - use crate::acp::custom_registry::{ - hydrate, hydrate_test_guard, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, - NpxSpec, - }; - let _registry_guard = hydrate_test_guard(); - for enabled in [false, true] { - let tmp = tempfile::tempdir().unwrap(); - let first_root = tmp.path().join("agent-a"); - let second_root = tmp.path().join("agent-b"); - fs::create_dir_all(&first_root).unwrap(); - fs::create_dir_all(&second_root).unwrap(); - let vault = disabled_skill_root(&first_root); - assert_eq!(vault, disabled_skill_root(&second_root)); - let source = if enabled { - vault.join("demo") - } else { - first_root.join("demo") - }; - fs::create_dir_all(&source).unwrap(); - fs::write(source.join("SKILL.md"), "owned by agent A").unwrap(); - let definitions = [ - ("task1b-vault-a", &first_root), - ("task1b-vault-b", &second_root), - ] - .map(|(id, root)| CustomAgentDef { - registry_id: id.into(), - name: id.into(), - description: String::new(), - version: "1.0.0".into(), - distribution_kind: CustomDistributionKind::Npx, - spec: CustomAgentSpec { - npx: Some(NpxSpec { - package: "test-agent@1.0.0".into(), - ..Default::default() - }), - ..Default::default() - }, - icon_url: None, - skills_shared_store: false, - skills_dir: Some(root.to_string_lossy().into_owned()), - source: Default::default(), - version_probe: None, - supports_mcp: true, - }); - assert!(hydrate(&definitions).is_empty()); - let agent = AgentType::custom(if enabled { - "task1b-vault-b" - } else { - "task1b-vault-a" - }) - .unwrap(); - let result = - tokio::runtime::Runtime::new() - .unwrap() - .block_on(acp_set_agent_skill_enabled( - agent, - AgentSkillScope::Global, - "demo".into(), - None, - enabled, - )); - hydrate(&[]); - assert!( - result.is_err(), - "shared vault must reject before mutation: {result:?}" - ); - assert!(result - .unwrap_err() - .to_string() - .contains("shared skill storage")); - assert_eq!( - fs::read_to_string(source.join("SKILL.md")).unwrap(), - "owned by agent A" - ); - assert!(!second_root.join("demo").exists()); - assert_eq!(vault.exists(), enabled); - } - } - - #[test] - fn skill_enabled_private_safety_read_only_root_cannot_share_vault() { - use crate::acp::custom_registry::{ - hydrate, hydrate_test_guard, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, - NpxSpec, - }; - let _registry_guard = hydrate_test_guard(); - let tmp = tempfile::tempdir().unwrap(); - temp_env::with_var("GEMINI_HOME", Some(tmp.path()), || { - let cli_root = - crate::parsers::antigravity::resolve_antigravity_cli_dir().join("skills"); - let root = cli_root.parent().unwrap().join("custom-skills"); - fs::create_dir_all(&cli_root).unwrap(); - fs::create_dir_all(root.join("task1b-readonly-vault-demo")).unwrap(); - fs::write( - root.join("task1b-readonly-vault-demo/SKILL.md"), - "owned by custom agent", - ) - .unwrap(); - assert!(is_read_only_skill_path(AgentType::Antigravity, &cli_root)); - assert_eq!(disabled_skill_root(&root), disabled_skill_root(&cli_root)); - let definition = CustomAgentDef { - registry_id: "task1b-vault-cli".into(), - name: "CLI Vault Test".into(), - description: String::new(), - version: "1.0.0".into(), - distribution_kind: CustomDistributionKind::Npx, - spec: CustomAgentSpec { - npx: Some(NpxSpec { - package: "test-agent@1.0.0".into(), - ..Default::default() - }), - ..Default::default() - }, - icon_url: None, - skills_shared_store: false, - skills_dir: Some(root.to_string_lossy().into_owned()), - source: Default::default(), - version_probe: None, - supports_mcp: true, - }; - assert!(hydrate(&[definition]).is_empty()); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let result = runtime.block_on(acp_set_agent_skill_enabled( - AgentType::custom("task1b-vault-cli").unwrap(), - AgentSkillScope::Global, - "task1b-readonly-vault-demo".into(), - None, - false, - )); - let listed = runtime - .block_on(acp_list_agent_skills(AgentType::Antigravity, None)) - .unwrap(); - hydrate(&[]); - assert!( - result.is_err(), - "read-only native root also scans its vault: {result:?}" - ); - assert!(result - .unwrap_err() - .to_string() - .contains("shared skill storage")); - assert_eq!( - fs::read_to_string(root.join("task1b-readonly-vault-demo/SKILL.md")).unwrap(), - "owned by custom agent" - ); - assert!(!disabled_skill_root(&root).exists()); - assert!(!listed - .skills - .iter() - .any(|item| item.id == "task1b-readonly-vault-demo")); - }); + assert!(!data_dir.exists()); } #[test] @@ -19846,6 +19621,131 @@ wire_api = "chat" }); } + #[test] + fn codex_delete_removes_only_exact_disabled_path_rules() { + let tmp = tempfile::tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let workspace = tmp.path().join("workspace"); + temp_env::with_var("CODEX_HOME", Some(&codex_home), || { + let skill = workspace.join(".codex/skills/demo"); + fs::create_dir_all(&skill).expect("create skill"); + fs::write(skill.join("SKILL.md"), "---\nname: demo\n---\n").expect("write skill"); + fs::create_dir_all(&codex_home).expect("create Codex home"); + let content_path = fs::canonicalize(skill.join("SKILL.md")).unwrap(); + let encoded_path = + toml_edit::Value::from(content_path.to_string_lossy().as_ref()).to_string(); + fs::write( + codex_home.join("config.toml"), + format!( + "# preserve this comment\nmodel = \"test-model\"\n\n\ + [[skills.config]]\npath = {encoded_path}\nenabled = false\n\n\ + [[skills.config]]\nname = \"demo\"\nenabled = false\n\n\ + [[skills.config]]\npath = {encoded_path}\nname = \"demo\"\nenabled = false\n\n\ + [[skills.config]]\npath = \"/unrelated/SKILL.md\"\nenabled = false\n" + ), + ) + .expect("write config"); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(acp_delete_agent_skill( + AgentType::Codex, + AgentSkillScope::Project, + "demo".into(), + Some(workspace.to_string_lossy().into_owned()), + )) + .expect("delete Codex skill"); + + assert!(!skill.exists()); + let raw = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + assert!(raw.contains("# preserve this comment")); + assert!(raw.contains("model = \"test-model\"")); + let parsed = parse_codex_skill_config(&raw, &codex_home).unwrap(); + assert_eq!(parsed.entries.len(), 3); + assert!(parsed.entries.iter().any(|entry| { + entry.name.as_deref() == Some("demo") && entry.path.is_none() && !entry.enabled + })); + assert!(parsed.entries.iter().any(|entry| { + entry.name.as_deref() == Some("demo") + && entry + .path + .as_deref() + .is_some_and(|path| same_skill_config_path(path, &content_path)) + && !entry.enabled + })); + assert!(parsed.entries.iter().any(|entry| { + entry.name.is_none() + && entry.path.as_deref() == Some(Path::new("/unrelated/SKILL.md")) + && !entry.enabled + })); + assert!(!parsed.entries.iter().any(|entry| { + entry.name.is_none() + && entry + .path + .as_deref() + .is_some_and(|path| same_skill_config_path(path, &content_path)) + && !entry.enabled + })); + }); + } + + #[test] + fn codex_delete_rule_cleanup_supports_inline_config_array() { + let tmp = tempfile::tempdir().expect("tempdir"); + let target = tmp.path().join("target/SKILL.md"); + let keep = tmp.path().join("keep/SKILL.md"); + let target_value = toml_edit::Value::from(target.to_string_lossy().as_ref()).to_string(); + let keep_value = toml_edit::Value::from(keep.to_string_lossy().as_ref()).to_string(); + let base = format!( + "# keep inline comment\n[skills]\nconfig = [{{ path = {target_value}, enabled = false }}, {{ path = {keep_value}, enabled = false }}]\n" + ); + + let updated = remove_codex_disabled_skill_path_rules( + &base, + tmp.path(), + std::slice::from_ref(&target), + ) + .expect("clean inline config") + .expect("target rule removed"); + + assert!(updated.contains("# keep inline comment")); + let parsed = parse_codex_skill_config(&updated, tmp.path()).expect("parse cleaned config"); + assert_eq!(parsed.entries.len(), 1); + assert!(parsed.entries[0] + .path + .as_deref() + .is_some_and(|path| same_skill_config_path(path, &keep))); + } + + #[test] + fn codex_delete_rule_cleanup_supports_root_inline_skills_table() { + let tmp = tempfile::tempdir().expect("tempdir"); + let target = tmp.path().join("target/SKILL.md"); + let keep = tmp.path().join("keep/SKILL.md"); + let target_value = toml_edit::Value::from(target.to_string_lossy().as_ref()).to_string(); + let keep_value = toml_edit::Value::from(keep.to_string_lossy().as_ref()).to_string(); + let base = format!( + "# keep root comment\nskills = {{ config = [{{ path = {target_value}, enabled = false }}, {{ path = {keep_value}, enabled = false }}], keep = \"value\" }}\n" + ); + + let updated = remove_codex_disabled_skill_path_rules( + &base, + tmp.path(), + std::slice::from_ref(&target), + ) + .expect("clean root inline skills table") + .expect("target rule removed"); + + assert!(updated.contains("# keep root comment")); + assert!(updated.contains("keep = \"value\"")); + let parsed = parse_codex_skill_config(&updated, tmp.path()).expect("parse cleaned config"); + assert_eq!(parsed.entries.len(), 1); + assert!(parsed.entries[0] + .path + .as_deref() + .is_some_and(|path| same_skill_config_path(path, &keep))); + } + #[cfg(unix)] #[test] fn codex_native_toggle_preserves_config_symlink() { diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 555af15543..f653a101cf 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -224,11 +224,16 @@ pub struct AcpListAgentSkillsParams { } pub async fn acp_list_agent_skills( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = acp_commands::acp_list_agent_skills(params.agent_type, params.workspace_path) - .await - .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + let result = acp_commands::acp_list_agent_skills_core( + params.agent_type, + params.workspace_path, + &state.data_dir, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(result)) } @@ -243,14 +248,16 @@ pub struct AcpSetAgentSkillEnabledParams { } pub async fn acp_set_agent_skill_enabled( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = acp_commands::acp_set_agent_skill_enabled( + let result = acp_commands::acp_set_agent_skill_enabled_core( params.agent_type, params.scope, params.skill_id, params.workspace_path, params.enabled, + &state.data_dir, ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; @@ -267,13 +274,15 @@ pub struct AcpReadAgentSkillParams { } pub async fn acp_read_agent_skill( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = acp_commands::acp_read_agent_skill( + let result = acp_commands::acp_read_agent_skill_core( params.agent_type, params.scope, params.skill_id, params.workspace_path, + &state.data_dir, ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; @@ -292,15 +301,17 @@ pub struct AcpSaveAgentSkillParams { } pub async fn acp_save_agent_skill( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - acp_commands::acp_save_agent_skill( + acp_commands::acp_save_agent_skill_core( params.agent_type, params.scope, params.skill_id, params.content, params.workspace_path, params.layout, + &state.data_dir, ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; @@ -317,13 +328,15 @@ pub struct AcpDeleteAgentSkillParams { } pub async fn acp_delete_agent_skill( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - acp_commands::acp_delete_agent_skill( + acp_commands::acp_delete_agent_skill_core( params.agent_type, params.scope, params.skill_id, params.workspace_path, + &state.data_dir, ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; diff --git a/src-tauri/tests/api_integration.rs b/src-tauri/tests/api_integration.rs index c3db4d5152..abfeaf9bad 100644 --- a/src-tauri/tests/api_integration.rs +++ b/src-tauri/tests/api_integration.rs @@ -15,7 +15,7 @@ //! Not covered: WebSocket attach (separate concern), endpoints that touch the //! Tauri webview (those are gated behind `tauri-runtime`). -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use axum_test::TestServer; use codeg_lib::app_state::AppState; @@ -46,6 +46,21 @@ async fn build_test_server() -> (TestServer, tempfile::TempDir, tempfile::TempDi (server, data_dir, static_dir) } +async fn build_test_server_at(data_dir: &Path) -> (TestServer, tempfile::TempDir) { + let static_dir = tempfile::tempdir().expect("static dir"); + let db = fresh_in_memory_db().await; + let state = Arc::new(AppState::new_for_test(db, data_dir.to_path_buf())); + let shutdown = Arc::new(ShutdownSignal::new()); + let router = build_router( + state, + TEST_TOKEN.to_string(), + static_dir.path().to_path_buf(), + shutdown, + ); + + (TestServer::new(router).expect("test server"), static_dir) +} + // ──────────────────────────────────────────────────────────────────────────── // Auth matrix // ──────────────────────────────────────────────────────────────────────────── @@ -219,6 +234,83 @@ async fn agent_skill_toggle_route_rejects_snake_case_params() { assert_eq!(resp.status_code(), 422); } +#[tokio::test] +async fn project_skill_vault_is_isolated_by_server_data_directory() { + let workspace = tempfile::tempdir().expect("workspace"); + let first_data = tempfile::tempdir().expect("first data dir"); + let second_data = tempfile::tempdir().expect("second data dir"); + let skill_id = "api-data-dir-isolation"; + let skill_dir = workspace.path().join(".kimi-code/skills").join(skill_id); + std::fs::create_dir_all(&skill_dir).expect("create project skill"); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: API data directory isolation\n---\n", + ) + .expect("write project skill"); + + let (first_server, _first_static) = build_test_server_at(first_data.path()).await; + let (second_server, _second_static) = build_test_server_at(second_data.path()).await; + let workspace_path = workspace.path().to_string_lossy().into_owned(); + + let toggle = first_server + .post("/api/acp_set_agent_skill_enabled") + .add_header("authorization", format!("Bearer {TEST_TOKEN}")) + .json(&json!({ + "agentType": "kimi_code", + "scope": "project", + "skillId": skill_id, + "workspacePath": workspace_path, + "enabled": false + })) + .await; + assert_eq!(toggle.status_code(), 200, "body: {}", toggle.text()); + let disabled: Value = toggle.json(); + assert_eq!(disabled["enabled"], false); + assert!(disabled["path"] + .as_str() + .expect("disabled path") + .starts_with(first_data.path().to_string_lossy().as_ref())); + + let first_list = first_server + .post("/api/acp_list_agent_skills") + .add_header("authorization", format!("Bearer {TEST_TOKEN}")) + .json(&json!({ + "agentType": "kimi_code", + "workspacePath": workspace_path + })) + .await; + assert_eq!(first_list.status_code(), 200, "body: {}", first_list.text()); + let first_body: Value = first_list.json(); + let first_skill = first_body["skills"] + .as_array() + .expect("skills array") + .iter() + .find(|skill| skill["id"] == skill_id) + .expect("first server sees its disabled skill"); + assert_eq!(first_skill["enabled"], false); + + let second_list = second_server + .post("/api/acp_list_agent_skills") + .add_header("authorization", format!("Bearer {TEST_TOKEN}")) + .json(&json!({ + "agentType": "kimi_code", + "workspacePath": workspace_path + })) + .await; + assert_eq!( + second_list.status_code(), + 200, + "body: {}", + second_list.text() + ); + let second_body: Value = second_list.json(); + assert!(!second_body["skills"] + .as_array() + .expect("skills array") + .iter() + .any(|skill| skill["id"] == skill_id)); +} + // ──────────────────────────────────────────────────────────────────────────── // Live feedback settings + submit gate // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/components/settings/skills-settings.test.tsx b/src/components/settings/skills-settings.test.tsx index 6b6cdafcff..2d06884a51 100644 --- a/src/components/settings/skills-settings.test.tsx +++ b/src/components/settings/skills-settings.test.tsx @@ -40,7 +40,23 @@ const messages = { disabled: "Disabled", toggleAria: "Toggle {skill} for {agent}", readOnly: "Built-in skills are always available.", - cannotIsolate: "This shared skill cannot be toggled independently.", + sharedRoot: + "This skill is installed in a directory shared by multiple agents, so it cannot be disabled for only this agent.", + managedElsewhere: + "This skill is managed from another Codeg settings page.", + storageConflict: + "Multiple skill entries or a destination conflict must be resolved before toggling.", + unsafeLink: + "This skill contains a link that would change or break if moved.", + crossFilesystem: + "This skill cannot be moved safely because its vault is on another filesystem.", + legacyState: + "This skill uses an older disabled layout and must be recovered manually.", + bundledDisabled: + "Codex bundled skills are disabled by the global Codex configuration.", + configError: + "The agent availability configuration could not be read safely.", + unavailable: "This skill cannot be toggled.", }, toasts: { ...enMessages.SkillsSettings.toasts, @@ -86,6 +102,7 @@ function skill(overrides: Partial = {}): AgentSkillItem { read_only: false, enabled: true, can_toggle: true, + toggle_reason: null, ...overrides, } } @@ -235,7 +252,13 @@ describe("SkillsSettings availability", () => { it("explains when a shared skill cannot be toggled independently", async () => { api.acpListAgentSkills.mockResolvedValue( - listResult(skill({ read_only: false, can_toggle: false })) + listResult( + skill({ + read_only: false, + can_toggle: false, + toggle_reason: "shared_root", + }) + ) ) renderSettings() @@ -246,10 +269,35 @@ describe("SkillsSettings availability", () => { expect(availability).toBeDisabled() expect(availability).toHaveAttribute( "title", - "This shared skill cannot be toggled independently." + "This skill is installed in a directory shared by multiple agents, so it cannot be disabled for only this agent." ) }) + it.each(["managed_elsewhere", "legacy_state"] as const)( + "disables edit and delete for %s skills that the backend refuses to mutate", + async (toggleReason) => { + api.acpListAgentSkills.mockResolvedValue( + listResult( + skill({ + can_toggle: false, + toggle_reason: toggleReason, + }) + ) + ) + + renderSettings() + + fireEvent.contextMenu(await screen.findByText("Demo Skill")) + + expect(screen.getByRole("menuitem", { name: "Edit" })).toHaveAttribute( + "data-disabled" + ) + expect(screen.getByRole("menuitem", { name: "Delete" })).toHaveAttribute( + "data-disabled" + ) + } + ) + it("reloads authoritative state and reports a localized error after failure", async () => { const reload = deferred() api.acpSetAgentSkillEnabled.mockRejectedValue( diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index 9cd6ee9ec8..c6c05d0caa 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -152,6 +152,15 @@ function skillDirectoryPath(skill: AgentSkillItem): string { return dirname(skill.path) } +function skillContentMutationBlocked(skill: AgentSkillItem | null): boolean { + return Boolean( + skill && + (skill.read_only || + skill.toggle_reason === "managed_elsewhere" || + skill.toggle_reason === "legacy_state") + ) +} + const SKILLS_LEFT_MIN_WIDTH = 300 const SKILLS_RIGHT_MIN_WIDTH = 420 @@ -255,6 +264,8 @@ export function SkillsSettings() { () => skillItems.find((item) => item.id === selectedSkillId) ?? null, [selectedSkillId, skillItems] ) + const selectedSkillMutationBlocked = + skillContentMutationBlocked(selectedSkill) const isEditingExisting = Boolean( selectedSkill && skillDraftId.trim() === selectedSkill.id @@ -988,13 +999,38 @@ export function SkillsSettings() { filteredSkills.map((skill) => { const isActive = skill.id === selectedSkillId const deleting = skillDeletingId === skill.id - const availabilityHint = !skill.can_toggle - ? skill.read_only - ? skillsT("availability.readOnly") - : skillsT("availability.cannotIsolate") - : skill.enabled + const mutationBlocked = skillContentMutationBlocked(skill) + const unavailableHint = (() => { + switch (skill.toggle_reason) { + case "read_only": + return skillsT("availability.readOnly") + case "shared_root": + return skillsT("availability.sharedRoot") + case "managed_elsewhere": + return skillsT("availability.managedElsewhere") + case "storage_conflict": + return skillsT("availability.storageConflict") + case "unsafe_link": + return skillsT("availability.unsafeLink") + case "cross_filesystem": + return skillsT("availability.crossFilesystem") + case "legacy_state": + return skillsT("availability.legacyState") + case "bundled_disabled": + return skillsT("availability.bundledDisabled") + case "config_error": + return skillsT("availability.configError") + default: + return skill.read_only + ? skillsT("availability.readOnly") + : skillsT("availability.unavailable") + } + })() + const availabilityHint = skill.can_toggle + ? skill.enabled ? skillsT("availability.enabled") : skillsT("availability.disabled") + : unavailableHint return ( @@ -1092,7 +1128,7 @@ export function SkillsSettings() { {t("actions.preview")} { handleEditSkill(skill).catch((err) => { console.error( @@ -1121,7 +1157,7 @@ export function SkillsSettings() { skillSaving || skillReading || deleting || - skill.read_only + mutationBlocked } onSelect={() => { handleRequestDeleteSkill(skill) @@ -1223,7 +1259,7 @@ export function SkillsSettings() { disabled={ skillSaving || skillReading || - Boolean(selectedSkill?.read_only) + selectedSkillMutationBlocked } > {skillSaving ? ( @@ -1297,8 +1333,7 @@ export function SkillsSettings() { setIsContentEditing((prev) => !prev) }} disabled={ - skillReading || - Boolean(selectedSkill?.read_only) + skillReading || selectedSkillMutationBlocked } > {isContentEditing ? ( @@ -1319,6 +1354,7 @@ export function SkillsSettings() { {isContentEditing ? (