fix: register extensions for the active integration only#3459
fix: register extensions for the active integration only#3459marcelsafin wants to merge 20 commits into
Conversation
extension add registered commands for every detected agent, and integration upgrade back-filled enabled extensions for non-active integrations. Maintainer direction on github#2948: treat the project as single-active. Only the active integration gets extension artifacts; use/switch rescaffold the target when the user selects it. - extension add now routes through the all-agents pass restricted to the active integration (only_agent), keeping detection and missing-skills-dir recovery safeguards. Projects without recorded init-options fall back to detection-based registration. - integration upgrade re-registers extensions only when upgrading the active integration, reversing the github#2886 back-fill for non-active targets at maintainer request. Fixes github#2948 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Scopes extension registration to the active integration and defers inactive integrations until use or switch.
Changes:
- Adds active-agent filtering to extension registration.
- Restricts upgrade re-registration to the active integration.
- Adds regression coverage for active-only behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/agents.py |
Adds optional single-agent filtering. |
src/specify_cli/extensions/__init__.py |
Routes extension installation to the active integration. |
src/specify_cli/integrations/_helpers.py |
Updates registration behavior documentation. |
src/specify_cli/integrations/_migrate_commands.py |
Skips extension backfill for inactive upgrades. |
tests/test_extensions.py |
Adapts registrar and naming tests. |
tests/integrations/test_integration_subcommand.py |
Tests active-only add and upgrade behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not active_agent or active_agent not in registrar.AGENT_CONFIGS: | ||
| return registrar.register_commands_for_all_agents( | ||
| manifest, | ||
| extension_dir, | ||
| self.project_root, | ||
| link_outputs=link_outputs, | ||
| create_missing_active_skills_dir=True, | ||
| ) |
| # Register commands with AI agents (active integration only, #2948) | ||
| registered_commands = {} | ||
| if register_commands: | ||
| registrar = CommandRegistrar() | ||
| # Register for all detected agents | ||
| registered_commands = registrar.register_commands_for_all_agents( | ||
| manifest, | ||
| dest_dir, | ||
| self.project_root, | ||
| link_outputs=link_commands, | ||
| create_missing_active_skills_dir=True, | ||
| registered_commands = self._register_commands_for_active_agent( | ||
| manifest, dest_dir, link_outputs=link_commands |
- Restrict the extension-add active-integration fallback to projects with no recorded active key at all. A recorded but unsupported key (e.g. "generic", deliberately excluded from AGENT_CONFIGS) no longer falls back to registering every detected agent. - Apply the same single-active rule to preset command overrides: PresetManager._register_commands now scopes registration to the active integration via only_agent. - Add PresetManager.register_enabled_presets_for_agent, mirroring ExtensionManager.register_enabled_extensions_for_agent, and call it from integration use/switch/upgrade (active only) alongside the existing extension re-registration so presets are rescaffolded on activation instead of being written for inactive integrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| return | ||
|
|
||
| resolver = PresetResolver(self.project_root) | ||
| for pack_id, metadata in self.registry.list_by_priority(): |
| active_agent = init_options.get("ai") | ||
|
|
||
| if not active_agent: | ||
| return registrar.register_commands_for_all_agents( | ||
| manifest, | ||
| extension_dir, | ||
| self.project_root, | ||
| link_outputs=link_outputs, | ||
| create_missing_active_skills_dir=True, | ||
| ) |
| # Single-active rule (#2948): preset command overrides register for | ||
| # the active integration only. A project without a recorded active | ||
| # integration falls back to detection-based registration for all | ||
| # agents; a recorded key with no registrar config (e.g. "generic") | ||
| # naturally yields no matches via only_agent instead of falling back. |
…osed, docs) - register_enabled_presets_for_agent now processes presets in reverse priority order (lowest-precedence first) so the highest-precedence preset is written last and actually wins after `integration use` rescaffolds two overlapping preset command overrides. Verified this reproduces the previously reported reversed-priority bug and that the fix resolves it. - _register_commands_for_active_agent now checks for the "ai" key's presence separately from its value: a missing key still falls back to detection-based registration for all agents, but a recorded, malformed value (non-string or empty, e.g. [] or null) now fails closed (registers nothing) instead of being treated as "no active integration" or reaching AGENT_CONFIGS.get() with an unhashable key and raising TypeError. - Updated docs/reference/presets.md and docs/reference/integrations.md to describe active-only preset/extension registration and clarify that `integration use`/`switch` is the activation point for installed extensions and presets, and that `upgrade` only re-registers them for the active integration. Adds regression tests: two enabled presets overriding the same command with different priorities (priority winner must survive `use` rescaffolding), and a malformed recorded `ai` value ([]) for `extension add`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| manifest.id, | ||
| preset_dir, | ||
| self.project_root, | ||
| only_agent=active_agent, |
| try: | ||
| updates: Dict[str, Any] = {} | ||
|
|
||
| registered_commands = self._register_commands(manifest, pack_dir) |
| merged_skills = list( | ||
| dict.fromkeys(existing_skills + registered_skills) | ||
| ) |
| if "ai" not in init_options: | ||
| return registrar.register_commands_for_all_agents( | ||
| manifest, | ||
| extension_dir, | ||
| self.project_root, | ||
| link_outputs=link_outputs, | ||
| create_missing_active_skills_dir=True, | ||
| ) |
| # integration falls back to detection-based registration for all | ||
| # agents; a recorded key with no registrar config (e.g. "generic") | ||
| # naturally yields no matches via only_agent instead of falling back. | ||
| active_agent = load_init_options(self.project_root).get("ai") |
…ics) Fixes five deeper active-only registration bugs surfaced by Copilot review after 2486c08, all in the presets/extensions single-active integration rule (github#2948): 1. presets: _reconcile_composed_commands (run after install/remove) bypassed the active-only filter entirely, writing composition-winner command files for every detected non-skill agent via register_commands_for_non_skill_agents. Added an only_agent param to that registrar method (mirroring register_commands_for_all_agents) and threaded it through all 5 reconciliation call sites. 2. presets: `integration use copilot` with --skills (ai_skills: true) wrote both the static .agent.md command file AND the SKILL.md mirror for the same override. Mirrored the extension path's ai_skills guard in both _register_commands and the reconciliation pass: a command-backed active agent running in skills mode is excluded from non-skill command registration. 3. presets: registered_skills was a flat list, so switching between two skill-mode agents (e.g. Claude -> Codex) and then removing the preset only restored the currently active agent's directory, permanently orphaning the other. _unregister_skills now restores every existing skill-mode agent directory instead of only the active one. 4. extensions: load_init_options() collapses "no file" and "corrupted file" into the same {}, so the round-2 fail-closed fix didn't actually distinguish them. Added a shared resolve_active_agent_for_registration() helper in _init_options.py that checks file existence separately from parse success, returning a distinct sentinel for "file absent" vs None for "corrupted or invalid". extensions/__init__.py now uses this helper. 5. presets: same corruption-collapsing bug in _register_commands's active_agent resolution. Now uses the same shared helper as (4). Adds regression tests for all five: reconciliation active-only filtering, copilot --skills dual-write prevention, multi-skill-agent switch+remove, and corrupted init-options fail-closed behavior for both extension add and preset add. Each test was verified to fail against the pre-fix code and pass with the fix. Targeted (883) and full (3923 passed, 109 skipped) suites pass; ruff check clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| skills_dir = _resolve_skills_dir(self.project_root, key) | ||
| if not skills_dir.is_dir(): | ||
| continue | ||
| try: | ||
| resolved = skills_dir.resolve() | ||
| except OSError: | ||
| continue |
| for skills_dir, agent_name in self._tracked_skill_agent_dirs(): | ||
| self._unregister_skills_in_dir(skill_names, skills_dir, agent_name) |
| if not ( | ||
| isinstance(integration, SkillsIntegration) | ||
| or getattr(integration, "_skills_mode", False) | ||
| ): |
…enance) 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>
| if not path.exists(): | ||
| return MISSING_INIT_OPTIONS_FILE |
| try: | ||
| updates: Dict[str, Any] = {} | ||
|
|
||
| registered_commands = self._register_commands(manifest, pack_dir) |
| 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) |
…fold reconciliation, shared skills dir) - _init_options.py: resolve_active_agent_for_registration() now treats a dangling init-options.json symlink as present (path.is_symlink() check alongside path.exists()), since Path.exists() follows symlinks and returns False for a broken one. Previously a broken symlink fell back to the legacy "no file" path and registered every detected agent instead of failing closed. - presets/__init__.py (register_enabled_presets_for_agent): the integration use/switch rescaffold path now collects affected command names across all presets processed and runs _reconcile_composed_commands/_reconcile_skills once after the loop, matching install/remove. Previously rescaffolding wrote each preset's raw content directly with no follow-up reconciliation, so a project-level override (the highest-priority layer) could be clobbered by a lower-precedence preset after switching agents. - presets/__init__.py (_unregister_skills): multiple integrations can share one physical skills directory (agy/codex/zed all resolve to .agents/skills). Provenance restoration now groups recorded agent entries by resolved directory and restores each physical directory exactly once, preferring the currently active agent's renderer when it owns that directory (otherwise any recorded owner, chosen deterministically). Previously each recorded agent key triggered its own restore pass against the same directory, with whichever agent was iterated last silently winning regardless of which agent was active. Adds regression tests for each: a dangling init-options.json symlink failing closed for both preset resolution and extension add; integration use rescaffold preserving a project override over a lower-priority preset; and a codex/agy shared-directory removal restoring the directory exactly once in the active agent's format. Targeted (tests/integrations/test_integration_subcommand.py, tests/test_presets.py, tests/test_extensions.py, tests/test_extension_skills.py, tests/integrations/test_integration_opencode.py, tests/integrations/test_integration_claude.py): 930 passed. Full suite: 3930 passed, 109 skipped. ruff check: clean on files touched by this change. Refs github#2948 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| agent_config | ||
| and is_ai_skills_enabled(init_options) | ||
| and agent_config.get("extension") != "/SKILL.md" | ||
| ): | ||
| return {} |
| resolved_agent = resolve_active_agent_for_registration(self.project_root) | ||
| if resolved_agent is MISSING_INIT_OPTIONS_FILE: | ||
| only_agent: Optional[str] = None | ||
| elif resolved_agent is None: | ||
| only_agent = "" |
| _ensure_safe_shared_directory( | ||
| self.project_root, skills_dir, | ||
| create=False, context="preset skills directory", | ||
| ) |
| @@ -1363,36 +1564,163 @@ def _register_skills( | |||
| skill_file.write_text(skill_content, encoding="utf-8") | |||
…conciliation Fix 4 issues from round-6 review of the active-only integration registration work (github#2948): - remove(): removed_cmd_names only collected primary command names from registered_commands + manifest aliases, missing commands that were only ever registered via skills mode (ai_skills guard returns no command names for command-backed integrations in skills mode). This skipped reconciliation entirely when removing a higher-priority skills-mode preset, causing _unregister_skills() to fall back to core/extension content instead of the surviving lower-priority preset's override. Now every command template's primary name is added to removed_cmd_names unconditionally. - _reconcile_composed_commands(): the "composed is None" branch (fires when no replace-strategy layer remains for a command, e.g. after removing a wrap/append preset's base) called unregister_commands() across every configured non-skill agent, ignoring only_agent. This deleted historical artifacts from integrations that were never active for the preset. Now filtered by only_agent like the rest of the file. - Added _validate_skill_subdir() helper (reusing _ensure_safe_shared_directory/_validate_safe_shared_directory from shared_infra.py) and applied it at every site that reads or writes an individual skill subdirectory (_register_skills, _unregister_skills_in_dir, _reconcile_skills' override_skills restoration loop). _safe_skills_dir_for_agent only validated the parent skills directory; a symlinked leaf subdirectory (e.g. .claude/skills/speckit-specify) would slip past that check since is_dir()/exists() follow symlinks, letting write_text/rmtree operate through it to an arbitrary location outside the project. Added regression tests: removing a higher-priority skills-only preset restores the surviving lower-priority preset's content; composed-is-None unregistration only touches the active agent; symlinked skill subdirectory rejected on restore; symlinked skill subdirectory rejected on write. Targeted (934) and full (3934 passed, 109 skipped) test suites and ruff check pass clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| if merged_commands != existing_commands: | ||
| updates["registered_commands"] = merged_commands | ||
|
|
||
| registered_skills = self._register_skills(manifest, pack_dir) |
| claude_dir = project_dir / ".gemini" / "commands" | ||
| cmd_file = claude_dir / f"{cmd_name}.toml" |
…caffold Fix remaining round-6 review findings on the active-only integration registration work (github#2948): - register_enabled_presets_for_agent(): registered_commands and registered_skills were merged and persisted together in a single registry.update() call after both the commands and skills phases ran. If _register_skills() raised, the per-preset try/except swallowed it before that update() call was reached, even though _register_commands() had already written a real command file to disk. That file became untracked, so preset removal could no longer clean it up. install_from_directory() already persists registered_commands immediately after the commands phase, before starting the independently fallible skills phase; rescaffold now does the same. - test_presets.py: renamed a misleading claude_dir variable (pointing at Gemini's command directory) in test_composed_none_unregister_respects_active_agent to reuse the existing gemini_commands_dir variable already defined earlier in the same test. Added regression test test_rescaffold_persists_commands_before_fallible_skills_phase: simulates a skills-phase failure during rescaffold and asserts the command file already written to disk is still tracked in registered_commands. Verified all other round-6 findings (preset active-integration scoping, preset reconciliation/remove paths, skills-mode switching, override precedence during rescaffold, skill-subdirectory symlink safety) are already addressed by prior commits in this branch; re-checked each against current code before concluding no further change was needed. Targeted (tests/test_presets.py, tests/test_extensions.py: 689 passed) and full (3935 passed, 109 skipped) suites and ruff check on changed files pass clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| existing_skills = self._valid_name_list( | ||
| metadata.get("registered_skills", []) | ||
| ) | ||
| owned_here = [ | ||
| name | ||
| for name in existing_skills | ||
| if (agent_skills_dir / name).is_dir() | ||
| ] | ||
| if owned_here: | ||
| self._unregister_extension_skills( | ||
| owned_here, ext_id, skills_dir=agent_skills_dir | ||
| ) | ||
| remaining = [ | ||
| name | ||
| for name in existing_skills | ||
| if (agent_skills_dir / name).is_dir() | ||
| ] | ||
| if remaining != existing_skills: | ||
| updates["registered_skills"] = remaining |
The skills -> command toggle cleanup in register_enabled_extensions_for_agent() recomputed the remaining tracked registered_skills names by checking only the toggling agent's own skills directory. Since registered_skills is a single flat list shared across every agent an extension has ever been activated under (skills are only ever rendered for the active agent, so there is no per-agent registry key), a name whose mirror still existed under a *different*, previously-active agent's directory was incorrectly dropped from tracking as soon as the current agent's own copy was removed. A later full removal only iterates registered_skills, so the orphaned mirror under the other agent's directory was never found or cleaned up. Add _extension_owned_skill_names(), which re-verifies ownership across every configured agent's skills directory (deduped by shared path) the same way the existing _unregister_extension_skills() fallback scan already does, keeping a name only when a SKILL.md with a matching metadata.source == "extension:<id>" marker is found somewhere - read-only, no directory creation, no symlink escape. Use it instead of re-checking only the toggling agent's own directory when recomputing what remains tracked after narrow stale-mirror cleanup. Add a red-first regression test: Auggie is activated in skills mode first (writing a mirror), then Copilot is activated in skills mode (writing its own mirror for the same names), then Copilot toggles to command mode. Before the fix, registered_skills lost both names entirely even though Auggie's mirrors were untouched on disk; after the fix tracking is preserved and a subsequent full removal correctly cleans up Auggie's remaining mirrors too. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
_extension_owned_skill_names() and the fast/fallback paths of its sibling _unregister_extension_skills() called skills_candidate.resolve() and then checked children relative to that already-resolved candidate. If the candidate directory itself (e.g. .gemini/skills) was a symlink pointing outside the project root, both the resolve() call and the subsequent containment check silently passed through the symlink instead of rejecting it: - _extension_owned_skill_names() would falsely attribute ownership to a marker-matching SKILL.md living outside the project. - _unregister_extension_skills()'s fast path (an explicit skills_dir, as passed by the toggle-cleanup call site) and its fallback scan (used during full extension removal) would both shutil.rmtree() the external directory, deleting unrelated content outside the project. Fix by validating the candidate directory itself with the existing _validate_safe_shared_directory() shared-infra helper before any probe or delete: it rejects a symlink at any path component (walking down from the project root, including the final component) without ever resolving through it, and is already used elsewhere in the codebase for the same class of shared-directory containment check. Unsafe candidates are skipped/refused rather than followed. Add red-first security regression tests reproducing each of the three call sites with a `.gemini/skills` symlink pointing at an external directory containing a marker-matching SKILL.md and an unrelated precious_file.txt: provenance inference must not attribute the name, and both the explicit-skills_dir fast path and the None-skills_dir fallback scan must leave the external directory and file untouched. Existing valid shared/deduped directory tests (e.g. agy/amp/codex/zed sharing .agents/skills) continue to pass, confirming legitimate shared directories still clean up correctly. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Fixed the latest extension-skill tracking and path-safety findings in 37cceb7 and 28561e6: global tracking now preserves mirrors across agent directories, and every extension skill probe/delete path rejects symlinked candidate roots before touching files. Full suite: 3948 passed, 109 skipped. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| # Reject the candidate directory itself (any path component, | ||
| # including the final one) if it's a symlink escaping the | ||
| # project root, before probing or deleting anything inside it. | ||
| # A caller-supplied skills_dir (e.g. a specific agent's | ||
| # directory resolved without side effects) could have been |
| # 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: |
…direct remove - _unregister_extension_skills(): omitting skills_dir now always triggers the full multi-directory fallback scan instead of narrowing to the currently active agent's directory. Previously, remove() (the only caller that omits skills_dir) would resolve the active agent's dir and take the scoped fast path, orphaning a previously-active second agent's extension skill mirror during full removal. - PresetManager.remove(): infer legacy flat-list registered_skills provenance (reusing _infer_legacy_skill_provenance from the prior rescaffold fix) before invoking _unregister_skills, so a direct `preset remove` with no intervening rescaffold/switch also restores every previously-active agent's directory instead of only the currently active one. Added regression tests: - test_remove_while_second_agent_still_in_skills_mode_cleans_up_first_agent_mirror - test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… absent ExtensionManager.unregister_agent_artifacts() converted its resolved agent_skills_dir to None whenever that directory didn't exist, before calling _unregister_extension_skills(). After 1d8f9e3, omitting skills_dir means "genuinely unscoped removal": scan every configured agent's directory, reserved for ExtensionManager.remove()'s full project cleanup. Since unregister_agent_artifacts is agent-scoped (used by switch to clean up the previous integration's artifacts), this caused it to delete every other agent's live extension skill mirrors whenever the target agent's own directory happened to be absent, e.g. unregistering an agent that was never activated. Fix: always pass the explicit, agent-scoped skills_dir, even when it doesn't exist on disk, so the fast path is a safe no-op for an absent directory instead of falling back to the all-agents scan. Registry reconciliation (dropping removed names from the flat registered_skills list) now only runs when the agent's directory actually exists, so an absent directory can't be misread as "these names were removed everywhere" and wipe tracking for mirrors that still legitimately live under other agents' directories. Added regression test: - test_unregister_agent_artifacts_stays_scoped_when_agent_dir_absent Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…facts
The present-directory branch of ExtensionManager.unregister_agent_artifacts()
recomputed "remaining" registered_skills only by checking whether each name
still existed under the just-cleaned agent's own directory. registered_skills
is a single flat list shared across every agent an extension was ever
activated under (skills are only ever rendered for the currently active
agent, so there's no per-agent registry key). Repro: auggie and copilot both
have mirrors for the same extension; unregister_agent_artifacts("auggie")
correctly removes auggie's own mirror, sees the names absent from auggie's
(now empty) directory, and stores an empty registered_skills list - even
though copilot's mirror is still live on disk and now untracked. A later full
remove() then reads an empty registry and leaves copilot's mirror orphaned.
Fix: after the agent-scoped cleanup, recompute remaining names with
_extension_owned_skill_names(), which scans every safe, configured agent
skills directory (not just the one just cleaned) and keeps a name only if a
marker-verified SKILL.md for this extension still exists somewhere. This is
the same helper already used for the analogous same-agent toggle-cleanup
case, so no new abstraction was introduced. Explicit per-agent cleanup,
marker ownership verification, and symlink/containment safety are unchanged.
Added regression test:
- test_unregister_agent_artifacts_preserves_tracking_for_other_agent_mirror
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the 2 latest findings plus follow-up cross-agent tracking regressions in 1d8f9e3, 0ab9a5f, and 31c9b97: full removal scans all safe mirrors, direct legacy preset removal infers provenance, and agent-scoped cleanup preserves other agents’ files and global tracking. Full suite: 3952 passed, 109 skipped; changed files pass ruff. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| resolved_agent = resolve_active_agent_for_registration(self.project_root) | ||
| if resolved_agent is MISSING_INIT_OPTIONS_FILE: | ||
| only_agent: Optional[str] = None | ||
| elif resolved_agent is None: | ||
| only_agent = "" | ||
| else: | ||
| only_agent = resolved_agent |
| for skills_dir, group in groups.items(): | ||
| agents = group["agents"] | ||
| renderer_agent = ( | ||
| active_agent if active_agent in agents else sorted(agents)[0] | ||
| ) | ||
| self._unregister_skills_in_dir(group["names"], skills_dir, renderer_agent) |
| try: | ||
| _validate_safe_shared_directory(self.project_root, skills_dir) | ||
| except (ValueError, OSError): | ||
| return |
…ill dirs Fixes 3 findings from the Copilot review on HEAD 31c9b97 (github#2948): 1. presets/__init__.py: remove()'s command reconciliation only recreated the surviving preset's content for the currently active agent, even though the removed preset's registered_commands could span multiple historical (now-inactive) agents recorded via prior rescaffolds. Now remove() captures every historical agent registered_commands actually targeted (before mutation) and passes it as extra_agents through _reconcile_composed_commands -> _register_for_non_skill_agents / _register_command_from_path -> registrar.register_commands_for_non_ skill_agents, so the active-only restriction for install/use is preserved while post-removal reconciliation restores every touched directory. 2. presets/__init__.py: the analogous gap existed for skills. _unregister_ skills() now returns {skills_dir: renderer_agent} for every directory it actually restored, and _reconcile_skills() accepts extra_skills_dirs to reconcile each of those directories (via a new apply_to_dir() helper), not only the currently active skills directory. _register_skills() gained optional target_dir/target_agent overrides (forcing create_missing_skills off for non-active directories) so a historical directory is only ever restored, never seeded with brand-new skills. 3. extensions/__init__.py: _extension_owned_skill_names() and both the fast and fallback paths of _unregister_extension_skills() validated only the parent skills_dir for symlink escape, then resolved skills_dir / skill_name and checked containment relative to that already-resolved parent. A per-skill child that is itself a symlink to a different, legitimate skill directory within the same (safe) root passed that containment check, so deleting/attributing through the symlink name could destroy or misattribute an unrelated skill reached only via the alias. All three call sites now run the shared _validate_safe_shared_directory() component-wise check against the full skills_dir / skill_name path (not just the parent) before any read or delete, rejecting a symlinked child outright rather than following it, even when its resolved target remains in-bounds. Regression tests added (all confirmed red against pre-fix code, green after): - test_remove_reconciles_command_for_every_historical_agent - test_remove_reconciles_skill_for_every_historical_agent - test_extension_owned_skill_names_rejects_symlinked_child_skill_dir - test_unregister_extension_skills_explicit_dir_rejects_symlinked_child - test_unregister_extension_skills_fallback_rejects_symlinked_child Tests: tests/test_presets.py (361), tests/test_extension_skills.py (69), tests/test_extensions.py (338) all pass; tests/integrations (1768 passed, 1 skipped) pass; full suite 3902 passed / 74 skipped (90 pre-existing, environment-only git-signing tests deselected — confirmed failing identically on the pre-change baseline due to local 1Password SSH-agent signing, unrelated to this change). ruff check clean on all changed files. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed all 3 latest findings in ab6c28c: preset removal now reconciles the surviving stack across every historical command/skill directory it touched, while per-skill child symlinks are rejected before ownership checks or deletion. Full suite: 3957 passed, 109 skipped; changed files pass ruff. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| registrar.register_commands_for_non_skill_agents( | ||
| commands, source_id, source_dir, self.project_root | ||
| commands, source_id, source_dir, self.project_root, | ||
| only_agent=only_agent, extra_agents=extra_agents, |
| self._register_skills( | ||
| filtered_manifest, pack_dir, | ||
| target_dir=skills_dir, target_agent=dir_agent or "", | ||
| ) |
| # tracked. Unregister it narrowly for this agent so | ||
| # command-mode and skills-mode artifacts stay mutually | ||
| # exclusive (#2948). | ||
| self._unregister_commands({agent_name: merged_commands.pop(agent_name)}) |
| registrar.unregister_commands( | ||
| {agent_name: stale_commands}, self.project_root | ||
| ) |
| @@ -1409,6 +2057,12 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: | |||
| skill_file = skill_subdir / "SKILL.md" | |||
… cleanup; validate registry-provided skill names Round 11 review findings (5 comments on HEAD ab6c28c), three root causes: A) Historical-agent reconciliation wrote surviving content to disk but discarded the returned per-agent write map, so the preset's own registered_commands/registered_skills never learned about directories reconciliation restored on its behalf. A later removal of that same preset then orphaned those directories. Added _merge_pack_registered_commands/_merge_pack_registered_skills and wired them into _reconcile_composed_commands and _reconcile_skills's apply_to_dir so every actual write is merged back into the winning preset's registry metadata. B) Command<->skills toggle on an already-active agent deleted the old artifact before the replacement registration ran, in both presets/__init__.py's register_enabled_presets_for_agent and extensions/__init__.py's register_enabled_extensions_for_agent. If the replacement step raised, both artifacts were lost. Deferred the destructive cleanup until after the replacement phase completes without raising (register-new-then-remove-old ordering); the mirror skills->command direction was already safe since the new command file is always registered unconditionally before any cleanup runs. C) _unregister_skills_in_dir and _infer_legacy_skill_provenance joined a registry-provided (untrusted) skill name directly onto a directory before any name-shape validation. An absolute in-project name discards the intended parent directory entirely (Path's "/" operator drops the left side for an absolute right side), letting a corrupted registry entry escape the intended skills subtree while still resolving inside the project root - passing the existing containment/symlink check. Added a centralized _is_safe_registry_skill_name guard (rejecting non-strings, empty strings, absolute paths, multi-component paths, and "."/".." ) and applied it before every path join derived from registry-provided skill names in both functions. Also fixed _infer_legacy_skill_provenance's unmatched-name fallback, which previously still attributed rejected names to fallback_agent even after the loop skipped them. Added red-first regressions for all three root causes, covering: a two-preset historical-command-agent survivor scenario, an analogous skill-agent survivor scenario, injected skills-phase failure during a preset command->skills toggle and the extension equivalent, a direct unit test of the new name-safety guard, an absolute-path escape attempt against _unregister_skills_in_dir, and a false-attribution attempt against _infer_legacy_skill_provenance. Tests: tests/test_presets.py (367 passed), tests/test_extension_skills.py + tests/test_extensions.py (408 passed), tests/integrations (1768 passed, 1 skipped), full suite tests -q deselecting the pre-existing 1Password-signing-affected tests/extensions/git/test_git_extension.py (3909 passed, 74 skipped, 90 deselected). ruff check clean on all changed files. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed all 5 latest findings in f19225a: historical reconciliation now persists the winning preset’s command/skill provenance, command-to-skills toggles retain the old artifact until replacement succeeds, and registry-provided skill names are validated before path use. Full suite: 3964 passed, 109 skipped; changed files pass ruff. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| if stale_command_names: | ||
| self._unregister_commands({agent_name: stale_command_names}) | ||
| merged_commands.pop(agent_name, None) | ||
| self.registry.update(pack_id, {"registered_commands": merged_commands}) |
| self._unregister_skills( | ||
| {agent_name: merged_skills.pop(agent_name)}, pack_dir | ||
| ) |
| self._unregister_extension_skills( | ||
| owned_here, ext_id, skills_dir=agent_skills_dir | ||
| ) |
| if deferred_stale_commands: | ||
| registrar.unregister_commands( | ||
| {agent_name: deferred_stale_commands}, self.project_root | ||
| ) |
…acts The command<->skills toggle cleanup added for github#2948 deferred destructive removal of the old-mode artifact until after the replacement registration call completed without raising. That was necessary but not sufficient: none of _register_skills(), _register_commands(), register_commands_for_agent(), or _register_extension_skills() raise on a missing source template, a safety-validation skip, or a corrupted manifest entry — they simply return an empty or partial result. Treating "did not raise" as "fully replaced" meant a stale artifact could still be deleted (or its tracking dropped) even though its specific replacement never actually landed, leaving neither artifact in place for that logical command/skill. Fix all four affected toggle directions by checking the replacement call's actual return value before allowing any destructive step: - presets command->skills (register_enabled_presets_for_agent): only unregister a stale command name once its corresponding skill name (via the existing _skill_names_for_command() helper) is confirmed present in the skills call's returned names for that agent; the remainder stays tracked and on disk. - presets skills->command (register_enabled_presets_for_agent): only unregister a stale skill name once its corresponding command name is confirmed present in the commands call's returned names for that agent, using the same helper. - extensions skills->command (register_enabled_extensions_for_agent): only remove a skill mirror once the matching command (mapped via the existing HookExecutor._skill_name_from_command() helper) is confirmed present in register_commands_for_agent's returned names. - extensions command->skills (register_enabled_extensions_for_agent): only remove a deferred stale command once its matching skill name is confirmed present in _register_extension_skills()'s returned names. All four reuse the existing command<->skill name-derivation helpers rather than inventing new mapping logic. Registry tracking is updated to retain exactly the unreplaced subset rather than being popped wholesale, so partially-successful toggles leave correct, minimal tracking behind. Added 8 new regression tests (4 presets, 4 extensions) covering both the fully-empty and genuinely-partial result cases for each of the four toggle directions, using real missing-source-file scenarios (not mocked return values) to exercise the actual code paths. Confirmed red before the fix and green after for all 8. Focused (test_presets.py, test_extension_skills.py, test_extensions.py, tests/integrations): 2551 passed, 1 skipped. Full suite (tests, excluding the pre-existing environment-local 1Password-signing git-extension failures): 3917 passed, 74 skipped, 90 deselected. ruff check: clean. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed all 4 latest findings in d0d152e: destructive command/skills toggle cleanup is now gated per artifact on the replacement actually returned by registration, preserving unreplaced files and tracking during empty or partial results in both presets and extensions. Full suite: 3972 passed, 109 skipped; changed files pass ruff. Posted on behalf of @marcelsafin by GitHub Copilot (model: GPT-5.6 Sol). @copilot review |
| fully_replaced = [ | ||
| cmd_name for cmd_name in deferred_stale_commands | ||
| if HookExecutor._skill_name_from_command(cmd_name) | ||
| in replaced_skill_names |
| for cmd_name in stale_command_names: | ||
| modern_name, legacy_name = self._skill_names_for_command(cmd_name) | ||
| if modern_name in replaced_skill_names or legacy_name in replaced_skill_names: | ||
| fully_replaced.append(cmd_name) |
| _register_presets_for_agent( | ||
| project_root, | ||
| target, | ||
| continuing="The integration switch succeeded, but installed presets may need re-registration.", | ||
| ) |
Fixes #2948
Root cause
Extension and preset registration paths wrote artifacts for every detected integration, even though projects have one active integration. In multi-integration projects, inactive agents accumulated commands or skills they had not selected, and preset skill tracking lacked per-agent provenance for reliable switching/removal.
Maintainer direction in #2948: treat the project as single-active. Add/install operations register for the active integration only;
integration use/switchand active upgrades are the activation and rescaffold points.Changes
extension addand enabled-preset registration restrict command output to the active integration while preserving existing detection and missing-directory safeguards.integration use/switchand activeintegration upgraderescaffold enabled artifacts for the active integration, then reconcile compositions and project overrides.registered_skillstracking now uses{agent_name: [skill_name, ...]}. Legacy flat lists migrate by inferring existing preset-owned skills from on-disksource: preset:<id>metadata before fallback attribution, so removal restores every directory a preset wrote to.integration upgradeleaves non-active integrations untouched, removing the previous back-fill behavior.Before / after
extension add gitwith claude active and codex installedintegration use codexintegration upgrade codexwhile inactiveTests
Regression coverage includes active-only extension/preset registration, use/switch/active-upgrade rescaffolding, project-override reconciliation after partial failures, command/skills mode toggles, symlink guards, and provenance-safe legacy
registered_skillsmigration.AI assistance
GitHub Copilot was used for implementation, tests, and review-feedback analysis. Agent-authored review-round comments and commits are disclosed separately per repository policy.