Skip to content

fix: register extensions for the active integration only#3459

Open
marcelsafin wants to merge 20 commits into
github:mainfrom
marcelsafin:fix/2948-active-only-extension-registration
Open

fix: register extensions for the active integration only#3459
marcelsafin wants to merge 20 commits into
github:mainfrom
marcelsafin:fix/2948-active-only-extension-registration

Conversation

@marcelsafin

@marcelsafin marcelsafin commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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 / switch and active upgrades are the activation and rescaffold points.

Changes

  • extension add and enabled-preset registration restrict command output to the active integration while preserving existing detection and missing-directory safeguards.
  • integration use / switch and active integration upgrade rescaffold enabled artifacts for the active integration, then reconcile compositions and project overrides.
  • Command-backed integrations using skills mode produce only skill artifacts; same-agent command/skills toggles remove stale opposite-mode extension and preset artifacts.
  • Preset registered_skills tracking now uses {agent_name: [skill_name, ...]}. Legacy flat lists migrate by inferring existing preset-owned skills from on-disk source: preset:<id> metadata before fallback attribution, so removal restores every directory a preset wrote to.
  • integration upgrade leaves non-active integrations untouched, removing the previous back-fill behavior.

Before / after

Action Before After
extension add git with claude active and codex installed registers both registers claude only
add/restore an enabled preset writes detected inactive integrations too writes the active integration only
integration use codex rescaffolds extensions only rescaffolds extensions and enabled presets
toggle active Copilot between command and skills mode can leave both artifact types removes stale opposite-mode extension and preset artifacts
integration upgrade codex while inactive back-fills artifacts leaves inactive integration untouched

Tests

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_skills migration.

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.

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>
@marcelsafin marcelsafin requested a review from mnriem as a code owner July 10, 2026 18:23
Copilot AI review requested due to automatic review settings July 10, 2026 18:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment on lines +1007 to +1014
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,
)
Comment on lines +1465 to +1469
# 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>
Copilot AI review requested due to automatic review settings July 10, 2026 20:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment thread src/specify_cli/presets/__init__.py Outdated
return

resolver = PresetResolver(self.project_root)
for pack_id, metadata in self.registry.list_by_priority():
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment on lines +1008 to +1017
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,
)
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +684 to +688
# 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>
Copilot AI review requested due to automatic review settings July 10, 2026 21:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

manifest.id,
preset_dir,
self.project_root,
only_agent=active_agent,
try:
updates: Dict[str, Any] = {}

registered_commands = self._register_commands(manifest, pack_dir)
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +747 to +749
merged_skills = list(
dict.fromkeys(existing_skills + registered_skills)
)
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment on lines +1014 to +1021
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,
)
Comment thread src/specify_cli/presets/__init__.py Outdated
# 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>
Copilot AI review requested due to automatic review settings July 10, 2026 21:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +1558 to +1564
skills_dir = _resolve_skills_dir(self.project_root, key)
if not skills_dir.is_dir():
continue
try:
resolved = skills_dir.resolve()
except OSError:
continue
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +1588 to +1589
for skills_dir, agent_name in self._tracked_skill_agent_dirs():
self._unregister_skills_in_dir(skill_names, skills_dir, agent_name)
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +1553 to +1556
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>
Copilot AI review requested due to automatic review settings July 10, 2026 22:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Comment thread src/specify_cli/_init_options.py Outdated
Comment on lines +72 to +73
if not path.exists():
return MISSING_INIT_OPTIONS_FILE
try:
updates: Dict[str, Any] = {}

registered_commands = self._register_commands(manifest, pack_dir)
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +1621 to +1628
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>
Copilot AI review requested due to automatic review settings July 10, 2026 22:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Comment on lines +718 to +722
agent_config
and is_ai_skills_enabled(init_options)
and agent_config.get("extension") != "/SKILL.md"
):
return {}
Comment on lines +879 to +883
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 = ""
Comment on lines +1612 to +1615
_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>
Copilot AI review requested due to automatic review settings July 10, 2026 22:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment on lines +774 to +777
if merged_commands != existing_commands:
updates["registered_commands"] = merged_commands

registered_skills = self._register_skills(manifest, pack_dir)
Comment thread tests/test_presets.py Outdated
Comment on lines +4620 to +4621
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comment on lines +1931 to +1949
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
marcelsafin and others added 2 commits July 11, 2026 09:51
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>
@marcelsafin

Copy link
Copy Markdown
Contributor Author

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

Copilot AI review requested due to automatic review settings July 11, 2026 08:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Comment on lines +1271 to +1275
# 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
Comment on lines +1914 to 1918
# 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:
marcelsafin and others added 3 commits July 11, 2026 10:56
…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>
@marcelsafin

Copy link
Copy Markdown
Contributor Author

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

Copilot AI review requested due to automatic review settings July 11, 2026 09:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Comment on lines +950 to +956
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
Comment on lines +1906 to +1911
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)
Comment on lines +1280 to +1283
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>
@marcelsafin

Copy link
Copy Markdown
Contributor Author

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

Copilot AI review requested due to automatic review settings July 11, 2026 11:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +1223 to +1225
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,
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +1431 to +1434
self._register_skills(
filtered_manifest, pack_dir,
target_dir=skills_dir, target_agent=dir_agent or "",
)
Comment thread src/specify_cli/presets/__init__.py Outdated
# 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)})
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment on lines +2040 to +2042
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>
@marcelsafin

Copy link
Copy Markdown
Contributor Author

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

Copilot AI review requested due to automatic review settings July 11, 2026 12:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +881 to +884
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})
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +859 to +861
self._unregister_skills(
{agent_name: merged_skills.pop(agent_name)}, pack_dir
)
Comment on lines +2107 to +2109
self._unregister_extension_skills(
owned_here, ext_id, skills_dir=agent_skills_dir
)
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment on lines +2136 to +2139
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>
@marcelsafin

Copy link
Copy Markdown
Contributor Author

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

Copilot AI review requested due to automatic review settings July 11, 2026 13:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.

Comment on lines +2161 to +2164
fully_replaced = [
cmd_name for cmd_name in deferred_stale_commands
if HookExecutor._skill_name_from_command(cmd_name)
in replaced_skill_names
Comment on lines +907 to +910
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)
Comment on lines +339 to +343
_register_presets_for_agent(
project_root,
target,
continuing="The integration switch succeeded, but installed presets may need re-registration.",
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extension commands aren't rendered as skills for non-active skills-mode agents on add/install/upgrade

2 participants