Files
github-spec-kit/tests/test_extension_update_hardening.py
Pascal THUET 118062eac4 harden: secure extension and preset archive downloads (#3141)
* harden: secure extension and preset archive downloads

Adopt the shared download-security primitives from #3140 across extension and
preset catalog, package, direct-URL, and ZIP-install flows:

- bound catalog, package, and inline manifest reads;
- verify catalog SHA-256 values when present;
- replace path-only extraction with bounded traversal/symlink-safe extraction;
- validate malformed hosts and ports before opening download URLs;
- handle normalized trailing-backslash directory entries consistently.

Redirect enforcement and checksum verification remain owned by the shared
helpers already on main; this commit wires them into extension and preset
behavior.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close archive and catalog download edge cases

Preflight ZIP central directories before ZipFile allocates them, bound both
declared and actual payload sizes, and reject ambiguous or non-portable archive
paths before extraction.

Keep extension update manifest selection consistent with extraction, reject
unsafe catalog-derived output filenames and malformed URL types, and escape
untrusted values in download errors.

Add regression coverage for parser differentials, collisions, platform-specific
filenames, bounded call sites, and failure ordering.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: address download security review feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close ZIP preflight review gaps

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update preflight and rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
2026-07-28 07:52:07 -05:00

2226 lines
71 KiB
Python

from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog
from pathlib import Path
import pytest
import shutil
import yaml
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
def _valid_update_manifest(version="1.1.0", command_name="speckit.test-ext.run"):
"""Return a manifest accepted by normal install preflight validation."""
return {
"schema_version": "1.0",
"extension": {
"id": "test-ext",
"name": "Test Ext",
"version": version,
"description": "Update hardening test extension",
},
"requires": {"speckit_version": ">=0"},
"provides": {
"commands": [
{
"name": command_name,
"file": "commands/run.md",
}
]
},
}
def _write_update_zip(zip_path, *, version="1.1.0", manifest=None):
"""Create the minimal archive required by the update preflight."""
import zipfile
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr(
"extension.yml",
yaml.safe_dump(
manifest
if manifest is not None
else _valid_update_manifest(version=version)
),
)
def _stub_available_update(monkeypatch, registry_entry):
"""Stub discovery for one installed extension with an available update."""
monkeypatch.setattr(
ExtensionManager,
"list_installed",
lambda self: [
{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}
],
)
monkeypatch.setattr(
ExtensionRegistry, "get", lambda self, ext_id: registry_entry
)
monkeypatch.setattr(
ExtensionCatalog,
"get_extension_info",
lambda self, ext_id: {
"id": "test-ext",
"name": "Test Ext",
"version": "1.1.0",
"download_url": "https://example.com/ext.zip",
},
)
monkeypatch.setattr("typer.confirm", lambda _: True)
def _update_backup_dirs(project_dir):
"""Return per-attempt update backups created for test-ext."""
backup_root = (
project_dir / ".specify" / "extensions" / ".backup"
)
if not backup_root.is_dir():
return []
return list(backup_root.glob("update-*-*"))
@pytest.fixture
def project_dir(tmp_path):
"""Create a mock spec-kit project directory."""
proj_dir = tmp_path / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
# Create required files for a project
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
return proj_dir
def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
# chdir into project_dir so _require_specify_project() succeeds
monkeypatch.chdir(project_dir)
# Corrupt extensions.yml
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Mock ExtensionManager to return an installed extension for resolution
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
# Mock confirmation to true
monkeypatch.setattr("typer.confirm", lambda _: True)
# Run update
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
assert not isinstance(result.exception, AttributeError)
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["test-ext"],
"hooks": ["not", "a", "dict"]
}))
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Use tmp_path-scoped zip so the test is hermetic and returns a Path for zip_path.exists()
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
assert not isinstance(result.exception, AttributeError)
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
"""Regression: extension update rollback must handle corrupted extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Write config with hooks: null; get_project_config() normalizes this to {}
# so the backup captures {} and the restored config will have hooks: {}.
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
# Mock update process to fail after backup
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
# Reach the destructive update phase, then fail the install so rollback is
# both necessary and safe to exercise. Download/preflight failures leave
# the installation untouched and deliberately skip destructive rollback.
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
def mock_install_fail(*args, **kwargs):
config_path.write_text(yaml.dump("CORRUPTED"))
raise Exception("Install failed")
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
monkeypatch.setattr(ExtensionManager, "install_from_zip", mock_install_fail)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# Should handle Exception and NOT crash with AttributeError during rollback
assert result.exit_code == 1
assert "Install failed" in result.output
assert not isinstance(result.exception, AttributeError)
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
restored_config = yaml.safe_load(config_path.read_text())
assert isinstance(restored_config, dict)
assert "hooks" in restored_config
assert restored_config["hooks"] == {}
def test_extension_update_skills_backup_no_collision(project_dir, monkeypatch):
"""Regression: skills agents name every command file SKILL.md (one per
command subdirectory). Backup must keep the per-command path so rollback
restores each skill's own content instead of overwriting them onto a
single backup path."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": {}}))
# Two skill command files with DISTINCT content, mirroring the claude
# skills layout (.claude/skills/<name>/SKILL.md).
skills_root = project_dir / ".claude" / "skills"
plan_file = skills_root / "speckit-plan" / "SKILL.md"
tasks_file = skills_root / "speckit-tasks" / "SKILL.md"
plan_file.parent.mkdir(parents=True)
tasks_file.parent.mkdir(parents=True)
plan_file.write_text("PLAN CONTENT")
tasks_file.write_text("TASKS CONTENT")
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {
"version": "1.0.0",
"enabled": True,
"registered_commands": {"claude": ["speckit.plan", "speckit.tasks"]},
})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Let download and validation succeed, then simulate remove clobbering the
# originals before install fails. Rollback must rely on the command backups.
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
def mock_remove(self, ext_id, keep_config=False):
plan_file.unlink()
tasks_file.unlink()
def mock_install_fail(*args, **kwargs):
raise Exception("Install failed")
monkeypatch.setattr(ExtensionManager, "remove", mock_remove)
monkeypatch.setattr(ExtensionManager, "install_from_zip", mock_install_fail)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
assert result.exit_code == 1
# Rollback must restore EACH skill's own content, not a single collided copy.
assert plan_file.exists() and tasks_file.exists()
assert plan_file.read_text() == "PLAN CONTENT"
assert tasks_file.read_text() == "TASKS CONTENT"
def test_extension_update_download_failure_never_removes_live_extension(
project_dir, monkeypatch
):
"""A download error happens before the destructive update boundary."""
import shutil
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
live_extension = project_dir / ".specify" / "extensions" / "test-ext"
live_extension.mkdir(parents=True)
sentinel = live_extension / "sentinel.txt"
sentinel.write_text("ORIGINAL")
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
def mock_download_fail(self, ext_id):
raise Exception("Download failed")
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
mock_download_fail,
)
removed_paths = []
real_rmtree = shutil.rmtree
def track_rmtree(path, *args, **kwargs):
removed_paths.append(Path(path).resolve())
return real_rmtree(path, *args, **kwargs)
monkeypatch.setattr(shutil, "rmtree", track_rmtree)
remove_calls = []
def track_remove(self, ext_id, keep_config=False):
remove_calls.append((ext_id, keep_config))
monkeypatch.setattr(ExtensionManager, "remove", track_remove)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Download failed" in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
assert live_extension.resolve() not in removed_paths
assert sentinel.read_text() == "ORIGINAL"
assert _update_backup_dirs(project_dir) == []
def test_extension_update_partial_extension_backup_preserves_live_tree(
project_dir, monkeypatch
):
"""A partial extension backup must never be restored over the live tree."""
import shutil
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
live_extension = project_dir / ".specify" / "extensions" / "test-ext"
live_extension.mkdir(parents=True)
first_file = live_extension / "first.txt"
second_file = live_extension / "second.txt"
first_file.write_text("FIRST ORIGINAL")
second_file.write_text("SECOND ORIGINAL")
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
real_copytree = shutil.copytree
def fail_partial_backup(src, dst, *args, **kwargs):
src = Path(src)
dst = Path(dst)
if src.resolve() == live_extension.resolve():
dst.mkdir(parents=True, exist_ok=True)
shutil.copy2(first_file, dst / first_file.name)
raise OSError("Extension backup failed")
return real_copytree(src, dst, *args, **kwargs)
monkeypatch.setattr(shutil, "copytree", fail_partial_backup)
remove_calls = []
def track_remove(self, ext_id, keep_config=False):
remove_calls.append((ext_id, keep_config))
monkeypatch.setattr(ExtensionManager, "remove", track_remove)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Extension backup failed" in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
assert first_file.read_text() == "FIRST ORIGINAL"
assert second_file.read_text() == "SECOND ORIGINAL"
assert _update_backup_dirs(project_dir) == []
def test_extension_update_rejects_symlinked_backup_root_without_cleanup(
project_dir, monkeypatch
):
"""A rejected backup root must never be followed during error cleanup."""
import os
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
outside = project_dir / "outside-backups"
outside.mkdir()
sentinel = outside / "keep.txt"
sentinel.write_text("KEEP", encoding="utf-8")
backup_root = (
project_dir / ".specify" / "extensions" / ".backup"
)
backup_root.parent.mkdir(parents=True)
try:
os.symlink(outside, backup_root)
except OSError:
pytest.skip("Current platform/user cannot create directory symlinks")
download_calls = []
remove_calls = []
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: download_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "symlinked directory" in result.output
assert "Rolling back" not in result.output
assert download_calls == []
assert remove_calls == []
assert backup_root.is_symlink()
assert sentinel.read_text(encoding="utf-8") == "KEEP"
def test_extension_update_partial_command_backup_preserves_live_commands(
project_dir, monkeypatch
):
"""An incomplete command backup must not make originals look newly added."""
import shutil
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
skills_root = project_dir / ".claude" / "skills"
plan_file = skills_root / "speckit-plan" / "SKILL.md"
tasks_file = skills_root / "speckit-tasks" / "SKILL.md"
plan_file.parent.mkdir(parents=True)
tasks_file.parent.mkdir(parents=True)
plan_file.write_text("PLAN ORIGINAL")
tasks_file.write_text("TASKS ORIGINAL")
registry_entry = {
"version": "1.0.0",
"enabled": True,
"registered_commands": {
"claude": ["speckit.plan", "speckit.tasks"]
},
}
_stub_available_update(monkeypatch, registry_entry)
backup_root = (
project_dir
/ ".specify"
/ "extensions"
/ ".backup"
)
real_copy2 = shutil.copy2
command_backup_count = 0
def fail_second_command_backup(src, dst, *args, **kwargs):
nonlocal command_backup_count
dst = Path(dst)
try:
relative_backup = dst.resolve().relative_to(
backup_root.resolve()
)
except ValueError:
return real_copy2(src, dst, *args, **kwargs)
if (
len(relative_backup.parts) < 2
or not relative_backup.parts[0].startswith("update-")
or relative_backup.parts[1] != "commands"
):
return real_copy2(src, dst, *args, **kwargs)
command_backup_count += 1
if command_backup_count == 2:
raise OSError("Command backup failed")
return real_copy2(src, dst, *args, **kwargs)
monkeypatch.setattr(shutil, "copy2", fail_second_command_backup)
remove_calls = []
def track_remove(self, ext_id, keep_config=False):
remove_calls.append((ext_id, keep_config))
monkeypatch.setattr(ExtensionManager, "remove", track_remove)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Command backup failed" in result.output
assert "Rolling back" not in result.output
assert command_backup_count == 2
assert remove_calls == []
assert plan_file.read_text() == "PLAN ORIGINAL"
assert tasks_file.read_text() == "TASKS ORIGINAL"
assert _update_backup_dirs(project_dir) == []
def test_extension_update_rolls_back_partial_remove(project_dir, monkeypatch):
"""A remove failure after its first mutation still restores the backup."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
live_extension = project_dir / ".specify" / "extensions" / "test-ext"
live_extension.mkdir(parents=True)
sentinel = live_extension / "sentinel.txt"
sentinel.write_text("ORIGINAL")
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
def fail_partial_remove(self, ext_id, keep_config=False):
sentinel.unlink()
raise OSError("Remove failed")
monkeypatch.setattr(ExtensionManager, "remove", fail_partial_remove)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Remove failed" in result.output
assert "Rolling back" in result.output
assert sentinel.read_text() == "ORIGINAL"
assert _update_backup_dirs(project_dir) == []
def test_extension_update_reports_success_when_backup_cleanup_fails(
project_dir, monkeypatch
):
"""A retained backup is isolated from the next successful update."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
live_extension = (
project_dir / ".specify" / "extensions" / "test-ext"
)
live_extension.mkdir(parents=True)
(live_extension / "sentinel.txt").write_text(
"ORIGINAL", encoding="utf-8"
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: None,
)
backup_root = (
project_dir
/ ".specify"
/ "extensions"
/ ".backup"
)
real_rmtree = shutil.rmtree
failed_cleanup = False
def fail_backup_cleanup_once(path, *args, **kwargs):
nonlocal failed_cleanup
candidate = Path(path)
if (
candidate.parent == backup_root
and candidate.name.startswith("update-")
and not failed_cleanup
):
failed_cleanup = True
raise OSError("Backup is locked")
return real_rmtree(path, *args, **kwargs)
monkeypatch.setattr(shutil, "rmtree", fail_backup_cleanup_once)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 0, result.output
assert "Updated to v1.1.0" in result.output
assert "Could not fully remove update backup" in result.output
assert "Rolling back" not in result.output
assert failed_cleanup
retained_backups = _update_backup_dirs(project_dir)
assert len(retained_backups) == 1
stale_config = (
retained_backups[0] / "config" / "stale-config.yml"
)
stale_config.parent.mkdir(parents=True, exist_ok=True)
stale_config.write_text("stale: true\n", encoding="utf-8")
# A second attempt receives a distinct backup directory. The retained
# config from the first completed update must never be resurrected.
_write_update_zip(mock_zip)
second_result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert second_result.exit_code == 0, second_result.output
assert not (live_extension / stale_config.name).exists()
assert _update_backup_dirs(project_dir) == retained_backups
def test_extension_update_reports_restored_state_when_cleanup_fails(
project_dir, monkeypatch
):
"""Post-rollback cleanup errors must not contradict restored state."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
live_extension = (
project_dir / ".specify" / "extensions" / "test-ext"
)
live_extension.mkdir(parents=True)
sentinel = live_extension / "sentinel.txt"
sentinel.write_text("ORIGINAL", encoding="utf-8")
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
def fail_partial_remove(self, ext_id, keep_config=False):
sentinel.unlink()
raise OSError("Remove failed")
monkeypatch.setattr(
ExtensionManager, "remove", fail_partial_remove
)
backup_root = (
project_dir
/ ".specify"
/ "extensions"
/ ".backup"
)
real_rmtree = shutil.rmtree
failed_cleanup = False
def fail_backup_cleanup_once(path, *args, **kwargs):
nonlocal failed_cleanup
candidate = Path(path)
if (
candidate.parent == backup_root
and candidate.name.startswith("update-")
and not failed_cleanup
):
failed_cleanup = True
raise OSError("Backup is locked")
return real_rmtree(path, *args, **kwargs)
monkeypatch.setattr(shutil, "rmtree", fail_backup_cleanup_once)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Rollback successful" in result.output
assert "Could not fully remove rollback backup" in result.output
assert "Rollback failed" not in result.output
assert sentinel.read_text(encoding="utf-8") == "ORIGINAL"
assert failed_cleanup
def test_extension_update_does_not_rollback_for_locked_download_cleanup(
project_dir, monkeypatch
):
"""A locked downloaded ZIP is non-fatal after the update commits."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: None,
)
real_unlink = Path.unlink
failed_cleanup = False
def fail_zip_cleanup_once(path, *args, **kwargs):
nonlocal failed_cleanup
if path == mock_zip and not failed_cleanup:
failed_cleanup = True
raise OSError("Downloaded ZIP is locked")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", fail_zip_cleanup_once)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 0, result.output
assert "Updated to v1.1.0" in result.output
assert "Could not remove downloaded update archive" in result.output
assert "Rolling back" not in result.output
assert failed_cleanup
def test_extension_update_preserves_install_error_when_zip_cleanup_fails(
project_dir, monkeypatch
):
"""ZIP cleanup must not replace the error that caused rollback."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
def fail_install(*args, **kwargs):
raise RuntimeError("Original install failure")
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
fail_install,
)
real_unlink = Path.unlink
failed_cleanup = False
def fail_zip_cleanup_once(path, *args, **kwargs):
nonlocal failed_cleanup
if path == mock_zip and not failed_cleanup:
failed_cleanup = True
raise OSError("Downloaded ZIP is locked")
return real_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", fail_zip_cleanup_once)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Original install failure" in result.output
assert "Failed: Downloaded ZIP is locked" not in result.output
assert "Could not remove downloaded update archive" in result.output
assert "Rollback successful" in result.output
assert failed_cleanup
@pytest.mark.parametrize(
("manifest", "expected_error"),
[
(
{
"extension": {
"id": "test-ext",
"name": "Test Ext",
"version": "1.1.0",
}
},
"Missing required field: schema_version",
),
(
{
**_valid_update_manifest(),
"requires": {"speckit_version": ">=9999"},
},
"Extension requires spec-kit >=9999",
),
],
)
def test_extension_update_validates_full_manifest_before_removal(
project_dir, monkeypatch, manifest, expected_error
):
"""Malformed or incompatible manifests must fail before remove()."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip, manifest=manifest)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert expected_error in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
assert install_calls == []
def test_extension_update_rejects_catalog_archive_version_mismatch(
project_dir, monkeypatch
):
"""The archive version must match the normalized catalog version."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip, version="1.0.1")
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert (
"Extension version mismatch: expected '1.1.0', got '1.0.1'"
in result.output
)
assert "Updated to v1.1.0" not in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
def test_extension_update_rejects_command_conflicts_before_removal(
project_dir, monkeypatch
):
"""Deterministic install conflicts belong on the safe side of remove()."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(
mock_zip,
manifest=_valid_update_manifest(
command_name="speckit.other.run"
),
)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
normalized_output = " ".join(result.output.split())
assert (
"Command 'speckit.other.run' must use extension namespace 'test-ext'"
in normalized_output
)
assert "Rolling back" not in result.output
assert remove_calls == []
assert install_calls == []
def test_extension_update_rejects_alias_parent_traversal_before_removal(
project_dir, monkeypatch
):
"""Alias '..' segments cannot escape through a symlinked subdirectory."""
import os
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
commands_dir = project_dir / ".github" / "agents"
commands_dir.mkdir(parents=True)
outside_dir = project_dir / "outside" / "subdir"
outside_dir.mkdir(parents=True)
outside_target = outside_dir.parent / "victim.agent.md"
outside_target.write_text("USER CONTENT", encoding="utf-8")
try:
os.symlink(outside_dir, commands_dir / "link")
except OSError:
pytest.skip("Current platform/user cannot create directory symlinks")
manifest = _valid_update_manifest()
manifest["provides"]["commands"][0]["aliases"] = [
"link/../victim"
]
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip, manifest=manifest)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
normalized_output = " ".join(result.output.split())
assert "Invalid alias 'link/../victim'" in normalized_output
assert "Rolling back" not in result.output
assert remove_calls == []
assert install_calls == []
assert outside_target.read_text(encoding="utf-8") == "USER CONTENT"
def test_extension_update_accepts_normalized_equivalent_archive_version(
project_dir, monkeypatch
):
"""Equivalent PEP 440 spellings must not cause a false mismatch."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
registry_entry = {"version": "1.0.0", "enabled": True}
_stub_available_update(monkeypatch, registry_entry)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip, version="v1.1")
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 0, result.output
assert remove_calls == ["test-ext"]
assert len(install_calls) == 1
assert "Updated to v1.1.0" in result.output
def test_extension_update_rejects_noncanonical_root_manifest_alias(
project_dir, monkeypatch
):
"""Root aliases must not select different manifests across filesystems."""
import zipfile
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
injected_manifest = _valid_update_manifest()
injected_manifest["extension"]["id"] = "injected"
mock_zip = project_dir / "mock.zip"
with zipfile.ZipFile(mock_zip, "w") as archive:
archive.writestr(
"EXTENSION.YML",
yaml.safe_dump(injected_manifest),
)
archive.writestr(
"repo/extension.yml",
yaml.safe_dump(_valid_update_manifest()),
)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "canonical 'extension.yml' casing" in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
assert install_calls == []
def test_extension_update_rejects_multiple_nested_archive_roots_before_removal(
project_dir, monkeypatch
):
"""Nested manifests must match install_from_zip's one-directory layout."""
import zipfile
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
with zipfile.ZipFile(mock_zip, "w") as archive:
archive.writestr(
"repo/extension.yml",
yaml.safe_dump(_valid_update_manifest()),
)
archive.writestr("other/content.txt", "SECOND ROOT")
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
normalized_output = " ".join(result.output.split())
assert (
"must contain exactly one top-level directory"
in normalized_output
)
assert "Rolling back" not in result.output
assert remove_calls == []
assert install_calls == []
def test_extension_update_accepts_root_file_beside_nested_manifest(
project_dir, monkeypatch
):
"""Root files do not create another directory during ZIP extraction."""
import zipfile
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch, {"version": "1.0.0", "enabled": True}
)
mock_zip = project_dir / "mock.zip"
with zipfile.ZipFile(mock_zip, "w") as archive:
archive.writestr(
"repo/extension.yml",
yaml.safe_dump(_valid_update_manifest()),
)
archive.writestr("README.md", "ROOT FILE")
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 0, result.output
assert remove_calls == ["test-ext"]
assert len(install_calls) == 1
def test_extension_update_rolls_back_commands_written_before_registry_add(
project_dir, monkeypatch
):
"""Rollback uses preflight paths when a failed install has no new registry."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
agents_dir = project_dir / ".github" / "agents"
prompts_dir = project_dir / ".github" / "prompts"
agents_dir.mkdir(parents=True)
prompts_dir.mkdir(parents=True)
registry_entry = {
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
}
_stub_available_update(monkeypatch, registry_entry)
command_name = "speckit.test-ext.new"
alias_names = [
"speckit.test-ext.group-a/shared",
"speckit.test-ext.group-b/shared",
]
new_nested_alias = "speckit.test-ext/new-group/deep"
manifest = _valid_update_manifest(command_name=command_name)
manifest["provides"]["commands"][0]["aliases"] = [
*alias_names,
new_nested_alias,
]
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip, manifest=manifest)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
new_command = agents_dir / f"{command_name}.agent.md"
new_prompt = prompts_dir / f"{command_name}.prompt.md"
new_nested_command = (
agents_dir / f"{new_nested_alias}.agent.md"
)
new_nested_prompt = (
prompts_dir / f"{new_nested_alias}.prompt.md"
)
existing_aliases = [
agents_dir / f"{alias_name}.agent.md"
for alias_name in alias_names
]
existing_alias_prompts = [
prompts_dir / f"{alias_name}.prompt.md"
for alias_name in alias_names
]
for index, alias_file in enumerate(existing_aliases):
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(f"USER COMMAND {index}", encoding="utf-8")
for index, prompt_file in enumerate(existing_alias_prompts):
prompt_file.parent.mkdir(parents=True, exist_ok=True)
prompt_file.write_text(f"USER PROMPT {index}", encoding="utf-8")
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
def fail_before_registry_add(*args, **kwargs):
new_command.write_text("NEW COMMAND", encoding="utf-8")
new_prompt.write_text("NEW PROMPT", encoding="utf-8")
new_nested_command.parent.mkdir(parents=True)
new_nested_prompt.parent.mkdir(parents=True)
new_nested_command.write_text(
"NEW NESTED COMMAND", encoding="utf-8"
)
new_nested_prompt.write_text(
"NEW NESTED PROMPT", encoding="utf-8"
)
for alias_file in existing_aliases:
alias_file.write_text("OVERWRITTEN COMMAND", encoding="utf-8")
for prompt_file in existing_alias_prompts:
prompt_file.write_text("OVERWRITTEN PROMPT", encoding="utf-8")
raise RuntimeError("Hook registration failed before registry add")
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
fail_before_registry_add,
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Hook registration failed before registry add" in result.output
assert "Rollback successful" in result.output
assert not new_command.exists()
assert not new_prompt.exists()
assert not new_nested_command.exists()
assert not new_nested_prompt.exists()
assert not (agents_dir / "speckit.test-ext").exists()
assert not (prompts_dir / "speckit.test-ext").exists()
for index, alias_file in enumerate(existing_aliases):
assert (
alias_file.read_text(encoding="utf-8")
== f"USER COMMAND {index}"
)
for index, prompt_file in enumerate(existing_alias_prompts):
assert (
prompt_file.read_text(encoding="utf-8")
== f"USER PROMPT {index}"
)
def test_extension_update_restores_canonical_and_legacy_commands(
project_dir, monkeypatch
):
"""Rollback restores every command location cleaned by unregister."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
canonical_dir = project_dir / ".opencode" / "commands"
legacy_dir = project_dir / ".opencode" / "command"
canonical_dir.mkdir(parents=True)
legacy_dir.mkdir(parents=True)
old_command = "speckit.test-ext.old"
canonical_file = canonical_dir / f"{old_command}.md"
legacy_file = legacy_dir / f"{old_command}.md"
canonical_file.write_text("CANONICAL USER COMMAND", encoding="utf-8")
legacy_file.write_text("LEGACY USER COMMAND", encoding="utf-8")
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {"opencode": [old_command]},
},
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
def remove_both_command_copies(
self, ext_id, keep_config=False
):
canonical_file.unlink()
legacy_file.unlink()
monkeypatch.setattr(
ExtensionManager,
"remove",
remove_both_command_copies,
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: (_ for _ in ()).throw(
RuntimeError("Install failed after legacy cleanup")
),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Rollback successful" in result.output
assert (
canonical_file.read_text(encoding="utf-8")
== "CANONICAL USER COMMAND"
)
assert (
legacy_file.read_text(encoding="utf-8")
== "LEGACY USER COMMAND"
)
def test_extension_update_removes_new_active_skills_root_on_rollback(
project_dir, monkeypatch
):
"""Rollback removes an active skills hierarchy created by registration."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"claude","ai_skills":true}',
encoding="utf-8",
)
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
},
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
new_skill = (
project_dir
/ ".claude"
/ "skills"
/ "speckit-test-ext-run"
)
def fail_after_creating_skill(*args, **kwargs):
new_skill.mkdir(parents=True)
(new_skill / "SKILL.md").write_text(
"GENERATED SKILL", encoding="utf-8"
)
raise RuntimeError("Hook registration failed after skill creation")
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
fail_after_creating_skill,
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Rollback successful" in result.output
assert not (project_dir / ".claude").exists()
def test_extension_update_removes_new_hermes_marker_on_rollback(
project_dir, monkeypatch, tmp_path
):
"""Rollback removes Hermes' local marker as well as global skill output."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"hermes","ai_skills":true}',
encoding="utf-8",
)
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
},
)
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
local_marker = project_dir / ".hermes" / "skills"
global_skill = (
fake_home
/ ".hermes"
/ "skills"
/ "speckit-test-ext-run"
)
def fail_after_creating_hermes_skill(*args, **kwargs):
local_marker.mkdir(parents=True)
global_skill.mkdir(parents=True)
(global_skill / "SKILL.md").write_text(
"GENERATED SKILL", encoding="utf-8"
)
raise RuntimeError("Hook registration failed after Hermes skill")
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
fail_after_creating_hermes_skill,
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Rollback successful" in result.output
assert not (project_dir / ".hermes").exists()
assert not (fake_home / ".hermes" / "skills").exists()
def test_extension_update_restores_command_symlinks(
project_dir, monkeypatch
):
"""Rollback preserves valid and dangling command symlinks byte-for-byte."""
import os
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
agents_dir = project_dir / ".github" / "agents"
prompts_dir = project_dir / ".github" / "prompts"
agents_dir.mkdir(parents=True)
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
},
)
alias_names = [
"speckit.test-ext.valid-link",
"speckit.test-ext.dangling-link",
]
manifest = _valid_update_manifest()
manifest["provides"]["commands"][0]["aliases"] = alias_names
mock_zip = project_dir / "mock.zip"
_write_update_zip(mock_zip, manifest=manifest)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
targets_dir = project_dir / "user-targets"
targets_dir.mkdir()
command_targets = [
targets_dir / "command-valid.md",
targets_dir / "command-dangling.md",
]
command_targets[0].write_text(
"USER COMMAND TARGET", encoding="utf-8"
)
alias_files = [
agents_dir / f"{alias_name}.agent.md"
for alias_name in alias_names
]
generated_prompt = (
prompts_dir / "speckit.test-ext.run.prompt.md"
)
command_link_texts = []
try:
for alias_file, target in zip(alias_files, command_targets):
link_text = os.path.relpath(target, alias_file.parent)
os.symlink(link_text, alias_file)
command_link_texts.append(link_text)
except OSError:
pytest.skip("Current platform/user cannot create file symlinks")
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: None,
)
def fail_before_registry_add(*args, **kwargs):
# Normal command rendering replaces command symlinks.
for alias_file in alias_files:
alias_file.unlink()
alias_file.write_text("GENERATED COMMAND", encoding="utf-8")
generated_prompt.parent.mkdir(parents=True)
generated_prompt.write_text(
"GENERATED PROMPT", encoding="utf-8"
)
raise RuntimeError("Hook registration failed after symlink writes")
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
fail_before_registry_add,
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Rollback successful" in result.output
for alias_file, link_text in zip(
alias_files, command_link_texts
):
assert alias_file.is_symlink()
assert os.readlink(alias_file) == link_text
assert (
command_targets[0].read_text(encoding="utf-8")
== "USER COMMAND TARGET"
)
assert not command_targets[1].exists()
assert not prompts_dir.exists()
def test_extension_update_rejects_symlinked_copilot_prompt_before_removal(
project_dir, monkeypatch
):
"""Prompt links are rejected before rendering can write through them."""
import os
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"copilot","ai_skills":false}',
encoding="utf-8",
)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
agents_dir = project_dir / ".github" / "agents"
prompts_dir = project_dir / ".github" / "prompts"
agents_dir.mkdir(parents=True)
prompts_dir.mkdir(parents=True)
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
},
)
command_name = "speckit.test-ext.linked-prompt"
mock_zip = project_dir / "mock.zip"
_write_update_zip(
mock_zip,
manifest=_valid_update_manifest(command_name=command_name),
)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
target = project_dir / "user-prompt.md"
target.write_text("USER PROMPT", encoding="utf-8")
prompt_file = prompts_dir / f"{command_name}.prompt.md"
link_text = os.path.relpath(target, prompt_file.parent)
try:
os.symlink(link_text, prompt_file)
except OSError:
pytest.skip("Current platform/user cannot create file symlinks")
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Cannot safely update symlinked Copilot prompt artifact" in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
assert install_calls == []
assert prompt_file.is_symlink()
assert os.readlink(prompt_file) == link_text
assert target.read_text(encoding="utf-8") == "USER PROMPT"
@pytest.mark.parametrize(
("active_agent", "ai_skills"),
[("gemini", False), ("copilot", True)],
)
def test_extension_update_ignores_inactive_copilot_artifacts(
project_dir, monkeypatch, active_agent, ai_skills
):
"""Preflight must inspect only outputs the active install can render."""
import json
import os
if not hasattr(os, "link") or not hasattr(os, "symlink"):
pytest.skip("links are unavailable")
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "init-options.json").write_text(
json.dumps({"ai": active_agent, "ai_skills": ai_skills}),
encoding="utf-8",
)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
if active_agent == "gemini":
(project_dir / ".gemini" / "commands").mkdir(parents=True)
agents_dir = project_dir / ".github" / "agents"
prompts_dir = project_dir / ".github" / "prompts"
agents_dir.mkdir(parents=True)
prompts_dir.mkdir(parents=True)
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
},
)
command_name = "speckit.test-ext.inactive-copilot"
mock_zip = project_dir / "mock.zip"
_write_update_zip(
mock_zip,
manifest=_valid_update_manifest(command_name=command_name),
)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
command_target = project_dir / "user-command.md"
command_target.write_text("USER COMMAND", encoding="utf-8")
command_file = agents_dir / f"{command_name}.agent.md"
prompt_target = project_dir / "user-prompt.md"
prompt_target.write_text("USER PROMPT", encoding="utf-8")
prompt_file = prompts_dir / f"{command_name}.prompt.md"
try:
os.link(command_target, command_file)
os.symlink(
os.path.relpath(prompt_target, prompt_file.parent),
prompt_file,
)
except OSError:
pytest.skip("Current filesystem cannot create test links")
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 0, result.output
assert remove_calls == ["test-ext"]
assert len(install_calls) == 1
assert command_file.read_text(encoding="utf-8") == "USER COMMAND"
assert prompt_file.is_symlink()
assert prompt_target.read_text(encoding="utf-8") == "USER PROMPT"
@pytest.mark.parametrize("broken_active_marker", [False, True])
def test_extension_update_ignores_unreachable_global_hermes_target(
project_dir, tmp_path, monkeypatch, broken_active_marker
):
"""Preflight skips Hermes when its project marker cannot be detected."""
import os
if not hasattr(os, "link"):
pytest.skip("hard links are unavailable")
monkeypatch.chdir(project_dir)
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
if broken_active_marker:
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"hermes","ai_skills":true}',
encoding="utf-8",
)
(project_dir / ".hermes").write_text(
"marker parent is not a directory",
encoding="utf-8",
)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
},
)
command_name = "speckit.test-ext.unmarked-hermes"
mock_zip = project_dir / "mock.zip"
_write_update_zip(
mock_zip,
manifest=_valid_update_manifest(command_name=command_name),
)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
from specify_cli.agents import CommandRegistrar
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS["hermes"]
commands_dir = registrar._resolve_agent_dir(
"hermes", agent_config, project_dir
)
output_name = registrar._compute_output_name(
"hermes", command_name, agent_config
)
artifact = commands_dir / f"{output_name}{agent_config['extension']}"
artifact.parent.mkdir(parents=True)
user_target = project_dir / "user-hermes-skill.md"
user_target.write_text("USER HERMES SKILL", encoding="utf-8")
try:
os.link(user_target, artifact)
except OSError:
pytest.skip("Current filesystem cannot create hard links")
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 0, result.output
assert remove_calls == ["test-ext"]
assert len(install_calls) == 1
assert artifact.read_text(encoding="utf-8") == "USER HERMES SKILL"
assert not (project_dir / ".hermes" / "skills").exists()
@pytest.mark.parametrize("artifact_kind", ["command", "prompt"])
def test_extension_update_rejects_hard_linked_artifacts_before_removal(
project_dir, monkeypatch, artifact_kind
):
"""Rendering must not mutate another name for a shared inode."""
import os
if not hasattr(os, "link"):
pytest.skip("hard links are unavailable")
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"copilot","ai_skills":false}',
encoding="utf-8",
)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
agents_dir = project_dir / ".github" / "agents"
prompts_dir = project_dir / ".github" / "prompts"
agents_dir.mkdir(parents=True)
prompts_dir.mkdir(parents=True)
_stub_available_update(
monkeypatch,
{
"version": "1.0.0",
"enabled": True,
"registered_commands": {},
},
)
command_name = "speckit.test-ext.hard-link"
mock_zip = project_dir / "mock.zip"
_write_update_zip(
mock_zip,
manifest=_valid_update_manifest(command_name=command_name),
)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
user_target = project_dir / "user-artifact.md"
user_target.write_text("USER CONTENT", encoding="utf-8")
if artifact_kind == "command":
artifact = agents_dir / f"{command_name}.agent.md"
else:
artifact = prompts_dir / f"{command_name}.prompt.md"
try:
os.link(user_target, artifact)
except OSError:
pytest.skip("Current filesystem cannot create hard links")
original_stat = user_target.stat()
remove_calls = []
install_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
monkeypatch.setattr(
ExtensionManager,
"install_from_zip",
lambda *args, **kwargs: install_calls.append(args),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Cannot safely update hard-linked generated artifact" in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
assert install_calls == []
assert artifact.read_text(encoding="utf-8") == "USER CONTENT"
assert user_target.read_text(encoding="utf-8") == "USER CONTENT"
assert artifact.stat().st_ino == original_stat.st_ino
assert artifact.stat().st_dev == original_stat.st_dev
def _write_owned_extension_skill(skill_dir, extension_id, body):
"""Write a SKILL.md whose metadata identifies its owning extension."""
skill_dir.mkdir(parents=True, exist_ok=True)
frontmatter = yaml.safe_dump(
{
"name": skill_dir.name,
"description": "Extension update rollback test",
"metadata": {"source": f"extension:{extension_id}"},
},
sort_keys=False,
)
(skill_dir / "SKILL.md").write_text(
f"---\n{frontmatter}---\n\n{body}\n",
encoding="utf-8",
)
def test_extension_update_rolls_back_registered_skill_artifacts(
project_dir, monkeypatch
):
"""Rollback restores old registered skills and removes newly created ones."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"claude","ai_skills":true}',
encoding="utf-8",
)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
skills_root = project_dir / ".claude" / "skills"
old_skill = skills_root / "speckit-test-ext-old"
new_skill = skills_root / "speckit-test-ext-new"
_write_owned_extension_skill(old_skill, "test-ext", "OLD SKILL")
(old_skill / "support.txt").write_text("OLD SUPPORT", encoding="utf-8")
registry_entry = {
"version": "1.0.0",
"enabled": True,
"registered_skills": [old_skill.name],
}
_stub_available_update(monkeypatch, registry_entry)
mock_zip = project_dir / "mock.zip"
_write_update_zip(
mock_zip,
manifest=_valid_update_manifest(
command_name="speckit.test-ext.new"
),
)
monkeypatch.setattr(
ExtensionCatalog,
"download_extension",
lambda self, ext_id: mock_zip,
)
def mock_remove(self, ext_id, keep_config=False):
shutil.rmtree(old_skill)
def mock_install_fail(*args, **kwargs):
# Simulate a write failure before ownership frontmatter is complete.
# The normal unregistrar must preserve unverifiable directories, so
# rollback relies on the absent-before-update snapshot to remove this.
new_skill.mkdir(parents=True)
(new_skill / "SKILL.md").write_text("---\n", encoding="utf-8")
raise RuntimeError("Install failed during skill registration")
monkeypatch.setattr(ExtensionManager, "remove", mock_remove)
monkeypatch.setattr(
ExtensionManager, "install_from_zip", mock_install_fail
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Rollback successful" in result.output
assert (old_skill / "SKILL.md").exists()
assert "OLD SKILL" in (old_skill / "SKILL.md").read_text(encoding="utf-8")
assert (old_skill / "support.txt").read_text(encoding="utf-8") == "OLD SUPPORT"
assert not new_skill.exists()
def test_extension_update_partial_skill_backup_preserves_live_skills(
project_dir, monkeypatch
):
"""An incomplete skill backup must abort before remove() touches live data."""
monkeypatch.chdir(project_dir)
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"claude","ai_skills":true}',
encoding="utf-8",
)
(project_dir / ".specify" / "extensions.yml").write_text(
yaml.safe_dump({"installed": ["test-ext"], "hooks": {}})
)
first_skill = (
project_dir / ".claude" / "skills" / "speckit-test-ext-old"
)
second_skill = (
project_dir / ".claude" / "skills" / "speckit-test-ext-second"
)
_write_owned_extension_skill(first_skill, "test-ext", "FIRST SKILL")
_write_owned_extension_skill(second_skill, "test-ext", "SECOND SKILL")
first_support = first_skill / "support.txt"
second_support = second_skill / "support.txt"
first_support.write_text("FIRST SUPPORT", encoding="utf-8")
second_support.write_text("SECOND SUPPORT", encoding="utf-8")
registry_entry = {
"version": "1.0.0",
"enabled": True,
"registered_skills": [first_skill.name, second_skill.name],
}
_stub_available_update(monkeypatch, registry_entry)
real_copytree = shutil.copytree
skill_backup_count = 0
def fail_partial_skill_backup(src, dst, *args, **kwargs):
nonlocal skill_backup_count
source = Path(src).resolve()
if source in {first_skill.resolve(), second_skill.resolve()}:
skill_backup_count += 1
if skill_backup_count == 2:
Path(dst).mkdir(parents=True, exist_ok=True)
shutil.copy2(
Path(src) / "SKILL.md",
Path(dst) / "SKILL.md",
)
raise OSError("Skill backup failed")
return real_copytree(src, dst, *args, **kwargs)
monkeypatch.setattr(shutil, "copytree", fail_partial_skill_backup)
remove_calls = []
monkeypatch.setattr(
ExtensionManager,
"remove",
lambda self, ext_id, keep_config=False: remove_calls.append(ext_id),
)
result = runner.invoke(
app,
["extension", "update", "test-ext"],
obj={"project_root": project_dir},
)
assert result.exit_code == 1
assert "Skill backup failed" in result.output
assert "Rolling back" not in result.output
assert remove_calls == []
assert skill_backup_count == 2
assert (first_skill / "SKILL.md").exists()
assert (second_skill / "SKILL.md").exists()
assert first_support.read_text(encoding="utf-8") == "FIRST SUPPORT"
assert second_support.read_text(encoding="utf-8") == "SECOND SUPPORT"
assert _update_backup_dirs(project_dir) == []