Skip to content

Commit 1c63273

Browse files
marcelsafinCopilot
andcommitted
fix: harden preset skill writes and rollback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7b546dd commit 1c63273

2 files changed

Lines changed: 139 additions & 11 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,6 +1973,7 @@ def apply_to_dir(
19731973
try:
19741974
from ..agents import CommandRegistrar
19751975
from .. import SKILL_DESCRIPTIONS
1976+
from ..shared_infra import _write_shared_text
19761977
registrar = CommandRegistrar()
19771978
content = top_layer["path"].read_text(encoding="utf-8")
19781979
fm, body = registrar.parse_frontmatter(content)
@@ -2027,8 +2028,10 @@ def apply_to_dir(
20272028
skill_content
20282029
)
20292030
)
2030-
(skill_subdir / "SKILL.md").write_text(
2031-
skill_content, encoding="utf-8"
2031+
_write_shared_text(
2032+
skills_dir,
2033+
skill_subdir / "SKILL.md",
2034+
skill_content,
20322035
)
20332036
wrote_override = True
20342037
if (
@@ -2361,6 +2364,7 @@ def _register_skills(
23612364
from .. import SKILL_DESCRIPTIONS, load_init_options
23622365
from ..agents import CommandRegistrar
23632366
from ..integrations import get_integration
2367+
from ..shared_infra import _write_shared_text
23642368

23652369
init_opts = load_init_options(self.project_root)
23662370
if not isinstance(init_opts, dict):
@@ -2479,7 +2483,9 @@ def _register_skills(
24792483
)
24802484

24812485
skill_file = skill_subdir / "SKILL.md"
2482-
skill_file.write_text(skill_content, encoding="utf-8")
2486+
_write_shared_text(
2487+
skills_dir, skill_file, skill_content
2488+
)
24832489
written.append(target_skill_name)
24842490
self._merge_pack_registered_skills(
24852491
manifest.id, {selected_ai: [target_skill_name]}
@@ -3091,16 +3097,17 @@ def install_from_directory(
30913097
"registered_skills": registered_skills,
30923098
})
30933099
except Exception:
3094-
# Roll back all side effects. Note: if _register_commands or
3095-
# _register_skills raised mid-way (e.g. I/O error after writing
3096-
# some files), registered_commands/registered_skills may be empty
3097-
# and some agent command files could be orphaned. Removing dest_dir
3098-
# (which contains .composed/) and the registry entry ensures the
3099-
# preset system is consistent even if orphaned files remain.
3100+
# Roll back all side effects. _register_skills persists each
3101+
# successful write immediately, so reload that partial map when
3102+
# a later template fails before the call can return.
31003103
if registered_commands:
31013104
self._unregister_commands(registered_commands)
3102-
if registered_skills:
3103-
self._unregister_skills(registered_skills, dest_dir)
3105+
persisted_metadata = self.registry.get(manifest.id) or {}
3106+
persisted_skills = persisted_metadata.get(
3107+
"registered_skills", registered_skills
3108+
)
3109+
if persisted_skills:
3110+
self._unregister_skills(persisted_skills, dest_dir)
31043111
try:
31053112
if dest_dir.exists():
31063113
shutil.rmtree(dest_dir)

tests/test_presets.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5516,6 +5516,49 @@ def fail_plan_source(path, *args, **kwargs):
55165516
not in remaining_skill.read_text(encoding="utf-8")
55175517
), "persisted partial ownership must remain removable"
55185518

5519+
def test_partial_skill_install_failure_rolls_back_persisted_writes(
5520+
self, project_dir, temp_dir, monkeypatch
5521+
):
5522+
"""Install rollback must reload partial skill ownership before removal."""
5523+
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
5524+
(project_dir / ".github" / "agents").mkdir(parents=True)
5525+
preset_dir = self._create_multi_command_preset(
5526+
temp_dir,
5527+
"partial-install-failure-preset",
5528+
["speckit.specify", "speckit.plan"],
5529+
)
5530+
manager = PresetManager(project_dir)
5531+
original_read_text = Path.read_text
5532+
5533+
def fail_plan_source(path, *args, **kwargs):
5534+
if (
5535+
path.name == "speckit.plan.md"
5536+
and path.parent.name == "commands"
5537+
and "partial-install-failure-preset" in path.parts
5538+
):
5539+
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid")
5540+
return original_read_text(path, *args, **kwargs)
5541+
5542+
monkeypatch.setattr(Path, "read_text", fail_plan_source)
5543+
with pytest.raises(UnicodeDecodeError):
5544+
manager.install_from_directory(preset_dir, "0.1.5")
5545+
5546+
assert not manager.registry.is_installed(
5547+
"partial-install-failure-preset"
5548+
)
5549+
skill_file = (
5550+
project_dir
5551+
/ ".github"
5552+
/ "skills"
5553+
/ "speckit-specify"
5554+
/ "SKILL.md"
5555+
)
5556+
assert (
5557+
not skill_file.exists()
5558+
or "preset:partial-install-failure-preset"
5559+
not in original_read_text(skill_file, encoding="utf-8")
5560+
), "rollback must not orphan a skill written before the later failure"
5561+
55195562
def test_toggle_skills_to_command_empty_result_preserves_old_skill(
55205563
self, project_dir, temp_dir
55215564
):
@@ -7216,6 +7259,84 @@ def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir):
72167259
"the symlink itself should be left alone"
72177260
)
72187261

7262+
def test_symlinked_skill_file_rejected_on_write(self, project_dir, temp_dir):
7263+
"""Registration must not follow a symlinked SKILL.md destination."""
7264+
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
7265+
(project_dir / ".github" / "agents").mkdir(parents=True)
7266+
skill_dir = (
7267+
project_dir / ".github" / "skills" / "speckit-specify"
7268+
)
7269+
skill_dir.mkdir(parents=True)
7270+
outside_file = temp_dir / "outside-registration.md"
7271+
outside_file.write_text("do-not-touch", encoding="utf-8")
7272+
(skill_dir / "SKILL.md").symlink_to(outside_file)
7273+
7274+
preset_dir = self._create_command_preset(
7275+
temp_dir,
7276+
"symlink-file-write-preset",
7277+
"speckit.specify",
7278+
"Symlink file write",
7279+
"preset body",
7280+
)
7281+
manager = PresetManager(project_dir)
7282+
with pytest.raises(ValueError):
7283+
manager.install_from_directory(preset_dir, "0.1.5")
7284+
7285+
assert outside_file.read_text(encoding="utf-8") == "do-not-touch"
7286+
assert (skill_dir / "SKILL.md").is_symlink()
7287+
7288+
def test_symlinked_skill_file_rejected_on_override_reconcile(
7289+
self, project_dir, temp_dir
7290+
):
7291+
"""Project-override reconciliation must not follow SKILL.md symlinks."""
7292+
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
7293+
skill_dir = (
7294+
project_dir / ".github" / "skills" / "speckit-specify"
7295+
)
7296+
skill_dir.mkdir(parents=True)
7297+
outside_file = temp_dir / "outside-reconciliation.md"
7298+
outside_file.write_text("do-not-touch", encoding="utf-8")
7299+
(skill_dir / "SKILL.md").symlink_to(outside_file)
7300+
7301+
preset_dir = self._create_command_preset(
7302+
temp_dir,
7303+
"symlink-override-preset",
7304+
"speckit.specify",
7305+
"Preset",
7306+
"Preset body",
7307+
)
7308+
manager = PresetManager(project_dir)
7309+
manager.registry.add(
7310+
"symlink-override-preset",
7311+
{
7312+
"version": "1.0.0",
7313+
"source": "local",
7314+
"enabled": True,
7315+
"priority": 10,
7316+
"registered_commands": {},
7317+
"registered_skills": {
7318+
"copilot": ["speckit-specify"]
7319+
},
7320+
},
7321+
)
7322+
installed_dir = (
7323+
manager.presets_dir / "symlink-override-preset"
7324+
)
7325+
shutil.copytree(preset_dir, installed_dir)
7326+
overrides_dir = (
7327+
project_dir / ".specify" / "templates" / "overrides"
7328+
)
7329+
overrides_dir.mkdir(parents=True)
7330+
(overrides_dir / "speckit.specify.md").write_text(
7331+
"---\ndescription: Override\n---\n\nOverride body\n",
7332+
encoding="utf-8",
7333+
)
7334+
7335+
manager._reconcile_skills(["speckit.specify"])
7336+
7337+
assert outside_file.read_text(encoding="utf-8") == "do-not-touch"
7338+
assert (skill_dir / "SKILL.md").is_symlink()
7339+
72197340
def test_is_safe_registry_skill_name_rejects_unsafe_values(self, project_dir):
72207341
"""Unit-test the centralized registry skill-name boundary guard.
72217342

0 commit comments

Comments
 (0)