fix: address fourth round of review feedback (skill registration provenance)

Replace the "enumerate every skill-mode directory and restore all of them"
approach from the previous round with precise per-agent provenance
tracking, per reviewer feedback that the enumerate-and-restore-everything
design was unsound:

- registered_skills changes from a flat List[str] to Dict[str, List[str]]
  (agent name -> skill names actually written), mirroring the shape
  registered_commands already uses. _register_skills now returns this
  per-agent mapping instead of a bare list, and every call site
  (register_enabled_presets_for_agent, install_from_directory, the
  _reconcile_skills "was this skill previously managed" check) is updated
  to read/merge the new shape. Legacy flat-list registry entries from
  before this change are still readable: writes self-migrate the format,
  and _normalize_registered_skills() handles the transitional read paths.

- _unregister_skills now restores exactly the agent directories recorded
  for a preset instead of guessing at every skill-mode integration that
  happens to exist on disk. This fixes two problems with the old
  enumerate-everything design: (1) it could silently overwrite or delete
  another preset's (or a user's) override in an agent directory the
  current preset never actually touched, and (2) it depended on
  transient per-process integration state (_skills_mode), which is unset
  in a fresh CLI invocation for mode-selectable integrations like Copilot
  --skills, permanently orphaning their overrides after a process
  restart. Registries written before this change (flat list, no agent
  provenance) fall back to best-effort restoration under only the
  currently active agent, matching the pre-existing guarantee level.

- Every directory resolved from persisted provenance is now validated
  through the project's shared symlink/containment guard
  (_ensure_safe_shared_directory) before any file in it is read, written,
  or removed, since restoration may target an agent that isn't currently
  active and its directory can't be assumed safe just because a name was
  recorded for it.

- _tracked_skill_agent_dirs() (the enumeration helper introduced last
  round) is removed; it's superseded by the provenance-based design.

Adds regression tests: a symlinked skills directory is rejected during
removal; removing one preset does not disturb a different preset's
override in another agent's directory; and a Copilot --skills
registration installed, then removed after switching agents in a fresh
PresetManager instance (simulating a new process), is still correctly
restored. Updates existing skill-registration assertions across
test_presets.py and test_integration_claude.py for the new per-agent
registry shape.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
marcelsafin
2026-07-11 00:05:57 +02:00
parent 12a3d67731
commit db78903dfb
3 changed files with 299 additions and 78 deletions

View File

@@ -16,7 +16,7 @@ import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Dict, List, Any
from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union
if TYPE_CHECKING:
from ..agents import CommandRegistrar
@@ -774,15 +774,14 @@ class PresetManager:
updates["registered_commands"] = merged_commands
registered_skills = self._register_skills(manifest, pack_dir)
if registered_skills:
existing_skills = metadata.get("registered_skills", [])
if not isinstance(existing_skills, list):
existing_skills = []
merged_skills = list(
dict.fromkeys(existing_skills + registered_skills)
)
if merged_skills != existing_skills:
updates["registered_skills"] = merged_skills
existing_skills = self._normalize_registered_skills(
metadata.get("registered_skills"), fallback_agent=agent_name
)
merged_skills = copy.deepcopy(existing_skills)
if registered_skills.get(agent_name):
merged_skills[agent_name] = registered_skills[agent_name]
if merged_skills != existing_skills:
updates["registered_skills"] = merged_skills
if updates:
self.registry.update(pack_id, updates)
@@ -1159,7 +1158,16 @@ class PresetManager:
for _pid, meta in presets_by_priority:
if not isinstance(meta, dict):
continue
if skill_name in meta.get("registered_skills", []):
recorded = meta.get("registered_skills", [])
if isinstance(recorded, dict):
in_any_agent = any(
skill_name in names
for names in recorded.values()
if isinstance(names, list)
)
else:
in_any_agent = skill_name in recorded
if in_any_agent:
was_managed = True
break
if was_managed:
@@ -1373,7 +1381,7 @@ class PresetManager:
self,
manifest: "PresetManifest",
preset_dir: Path,
) -> List[str]:
) -> Dict[str, List[str]]:
"""Generate SKILL.md files for preset command overrides.
For every command template in the preset, checks whether a
@@ -1388,13 +1396,16 @@ class PresetManager:
preset_dir: Installed preset directory.
Returns:
List of skill names that were written (for registry storage).
``{agent_name: [skill_name, ...]}`` for the single active
agent skills were written for (empty if none were written),
matching the shape ``registered_commands`` already uses so the
two can be tracked/restored consistently (#2948).
"""
command_templates = [
t for t in manifest.templates if t.get("type") == "command"
]
if not command_templates:
return []
return {}
# Filter out extension command overrides if the extension isn't installed,
# matching the same logic used by _register_commands().
@@ -1409,11 +1420,11 @@ class PresetManager:
filtered.append(cmd)
if not filtered:
return []
return {}
skills_dir = self._get_skills_dir()
if not skills_dir:
return []
return {}
from .. import SKILL_DESCRIPTIONS, load_init_options
from ..agents import CommandRegistrar
@@ -1423,8 +1434,8 @@ class PresetManager:
if not isinstance(init_opts, dict):
init_opts = {}
selected_ai = init_opts.get("ai")
if not isinstance(selected_ai, str):
return []
if not isinstance(selected_ai, str) or not selected_ai:
return {}
ai_skills_enabled = is_ai_skills_enabled(init_opts)
registrar = CommandRegistrar()
integration = get_integration(selected_ai)
@@ -1525,68 +1536,115 @@ class PresetManager:
skill_file.write_text(skill_content, encoding="utf-8")
written.append(target_skill_name)
return written
return {selected_ai: written} if written else {}
def _tracked_skill_agent_dirs(self) -> List[tuple]:
"""Return (skills_dir, agent_name) pairs for every skill-mode
integration directory that currently exists under the project root.
@staticmethod
def _normalize_registered_skills(
value: Any, fallback_agent: Optional[str] = None
) -> Dict[str, List[str]]:
"""Normalize a ``registered_skills`` registry value to per-agent form.
``registered_skills`` only tracks skill *names*, not which agent
directories they were written under, so a preset used first under
one skill-mode agent and later switched to another can have live
overrides in both directories at removal time. Restoring every
existing skill-mode directory (instead of only the currently active
one) ensures none of them are left permanently orphaned.
The registry stores ``registered_skills`` as ``Dict[str, List[str]]``
(agent name -> skill names actually written for that agent),
mirroring ``registered_commands``. Older registries predate that
provenance and stored a flat ``List[str]`` with no record of which
agent directory the names were written under; since that can't be
recovered, ``fallback_agent`` (when given) attributes the legacy
list to the agent currently being processed so the format
self-migrates on the next write. Without a fallback agent, legacy
lists are dropped rather than guessed at.
"""
if isinstance(value, dict):
return {
agent: list(names)
for agent, names in value.items()
if isinstance(agent, str) and isinstance(names, list)
}
if isinstance(value, list) and value and fallback_agent:
return {fallback_agent: [n for n in value if isinstance(n, str)]}
return {}
Multiple integration keys can share the same physical directory
(e.g. ``agy``/``codex``/``zed`` all use ``.agents/skills``); only one
representative agent name is kept per unique resolved directory so
each physical directory is processed exactly once.
def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]:
"""Resolve ``agent_name``'s skills directory, validated for safety.
Unlike :meth:`_get_skills_dir` (which resolves only the *currently
active* integration via init-options), this resolves an arbitrary
agent's directory from persisted provenance so a preset's skill
registrations can be restored/cleaned up under an agent that isn't
currently active. The candidate directory is validated through the
project's shared symlink/containment guard before any file in it is
touched; directories that don't exist or fail validation are
skipped rather than raising.
"""
from .. import _get_skills_dir as _resolve_skills_dir
from ..integrations import INTEGRATION_REGISTRY
from ..integrations.base import SkillsIntegration
from ..shared_infra import _ensure_safe_shared_directory
seen: Dict[Path, str] = {}
for key in sorted(INTEGRATION_REGISTRY):
integration = INTEGRATION_REGISTRY[key]
if not (
isinstance(integration, SkillsIntegration)
or getattr(integration, "_skills_mode", False)
):
continue
skills_dir = _resolve_skills_dir(self.project_root, key)
if not skills_dir.is_dir():
continue
try:
resolved = skills_dir.resolve()
except OSError:
continue
seen.setdefault(resolved, key)
skills_dir = _resolve_skills_dir(self.project_root, agent_name)
try:
_ensure_safe_shared_directory(
self.project_root, skills_dir,
create=False, context="preset skills directory",
)
except (ValueError, OSError):
return None
return skills_dir
return [(path, agent) for path, agent in seen.items()]
def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
def _unregister_skills(
self,
registered_skills: Union[Dict[str, List[str]], List[str]],
preset_dir: Path,
) -> None:
"""Restore original SKILL.md files after a preset is removed.
For each skill that was overridden by the preset, attempts to
regenerate the skill from the core command template. If no core
template exists, the skill directory is removed.
Restores across every existing skill-mode agent directory (see
:meth:`_tracked_skill_agent_dirs`), not just the currently active
integration, so switching integrations before removal can't leave a
preset override behind permanently.
``registered_skills`` records exactly which agent directories this
preset actually wrote to (see :meth:`_register_skills`), so removal
restores precisely those directories rather than guessing at every
skill-mode agent that happens to exist on disk. Each directory is
re-resolved and safety-validated at removal time (see
:meth:`_safe_skills_dir_for_agent`) since it may belong to an agent
that isn't currently active.
Args:
skill_names: List of skill names written by the preset.
registered_skills: Per-agent skill names written by the preset
(``{agent_name: [skill_name, ...]}``), or a legacy flat
``List[str]`` from a registry written before this
provenance tracking existed.
preset_dir: The preset's installed directory (may already be deleted).
"""
if not skill_names:
if not registered_skills:
return
for skills_dir, agent_name in self._tracked_skill_agent_dirs():
self._unregister_skills_in_dir(skill_names, skills_dir, agent_name)
if isinstance(registered_skills, dict):
for agent_name, skill_names in registered_skills.items():
if not skill_names:
continue
skills_dir = self._safe_skills_dir_for_agent(agent_name)
if skills_dir is None:
continue
self._unregister_skills_in_dir(skill_names, skills_dir, agent_name)
return
# Legacy flat-list format: no record of which agent directory these
# names were written under, so best-effort restore is limited to the
# currently active agent's directory (the pre-provenance behaviour).
skills_dir = self._get_skills_dir()
if not skills_dir:
return
from .. import load_init_options
init_opts = load_init_options(self.project_root)
if not isinstance(init_opts, dict):
init_opts = {}
selected_ai = init_opts.get("ai")
self._unregister_skills_in_dir(
registered_skills,
skills_dir,
selected_ai if isinstance(selected_ai, str) else None,
)
def _unregister_skills_in_dir(
self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str]
@@ -1760,11 +1818,11 @@ class PresetManager:
"enabled": True,
"priority": priority,
"registered_commands": {},
"registered_skills": [],
"registered_skills": {},
})
registered_commands: Dict[str, List[str]] = {}
registered_skills: List[str] = []
registered_skills: Dict[str, List[str]] = {}
try:
# Register command overrides with AI agents and persist the result
# immediately so cleanup can recover even if installation stops