Support controlled multi-install for safe AI agent integrations (#2389)

* support controlled multi-install integrations

* fix: harden multi-install integration state

* refactor: isolate integration runtime helpers

* fix: address copilot review feedback

* fix: address follow-up copilot feedback

* fix: tighten integration switch semantics

* fix: address final copilot review feedback

* fix: harden integration manifest read errors

* fix: refuse symlinked shared infra paths

* test: filter expected self-test preset warning

* test: address copilot review nits

* refactor: centralize safe shared infra writes

* fix: use no-follow writes for shared infra

* fix: keep default integration atomic on template refresh

* fix: harden shared infra error paths

* fix: preflight shared infra and future state schemas

* fix: support nested shared scripts during preflight

* test: tolerate wrapped schema error output

* fix: use safe default mode for shared text writes

* fix: use posix paths in shared skip output

* fix: share project guard for integration use

* fix: centralize spec-kit project guards

* fix: use posix project paths in cli output

* fix: harden shared manifest and upgrade refresh
This commit is contained in:
Pascal THUET
2026-05-01 18:54:41 +02:00
committed by GitHub
parent 63cad6ace6
commit 38fd1f6cc2
30 changed files with 2593 additions and 481 deletions

View File

@@ -1,13 +1,21 @@
"""Tests for --integration flag on specify init (CLI-level)."""
import io
import json
import os
import pytest
import yaml
from rich.console import Console
from tests.conftest import strip_ansi
class _NoopConsole:
def print(self, *args, **kwargs):
pass
def _normalize_cli_output(output: str) -> str:
output = strip_ansi(output)
output = " ".join(output.split())
@@ -254,6 +262,310 @@ class TestInitIntegrationFlag:
normalized = " ".join(captured.out.split())
assert "specify integration upgrade --force" in normalized
def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):
"""Invalid shared manifests warn before falling back to a new manifest."""
from specify_cli import _install_shared_infra
project = tmp_path / "bad-shared-manifest-test"
project.mkdir()
integrations_dir = project / ".specify" / "integrations"
integrations_dir.mkdir(parents=True)
manifest_path = integrations_dir / "speckit.manifest.json"
manifest_path.write_text("{not json", encoding="utf-8")
_install_shared_infra(project, "sh")
captured = capsys.readouterr()
assert "Could not read shared infrastructure manifest" in captured.out
assert "A new shared manifest will be created" in captured.out
def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys):
"""Non-UTF-8 shared manifests warn before falling back to a new manifest."""
from specify_cli import _install_shared_infra
project = tmp_path / "bad-shared-manifest-encoding-test"
project.mkdir()
integrations_dir = project / ".specify" / "integrations"
integrations_dir.mkdir(parents=True)
manifest_path = integrations_dir / "speckit.manifest.json"
manifest_path.write_bytes(b"\xff\xfe\x00")
_install_shared_infra(project, "sh")
captured = capsys.readouterr()
assert "Could not read shared infrastructure manifest" in captured.out
assert "A new shared manifest will be created" in captured.out
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
"""Shared script refreshes must not follow destination symlinks."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-script-test"
project.mkdir()
(project / ".specify").mkdir()
outside = tmp_path / "outside-script.sh"
outside.write_text("# outside\n", encoding="utf-8")
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
os.symlink(outside, scripts_dir / "common.sh")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_install_shared_infra(project, "sh", force=True)
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
"""Shared template installs must not follow destination symlinks."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-template-test"
project.mkdir()
(project / ".specify").mkdir()
outside = tmp_path / "outside-template.md"
outside.write_text("# outside\n", encoding="utf-8")
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
os.symlink(outside, templates_dir / "plan-template.md")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_install_shared_infra(project, "sh", force=True)
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path):
"""Template-only refreshes must not follow destination symlinks."""
from specify_cli import _refresh_shared_templates
project = tmp_path / "symlink-refresh-test"
project.mkdir()
(project / ".specify").mkdir()
outside = tmp_path / "outside-refresh.md"
outside.write_text("# outside\n", encoding="utf-8")
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
os.symlink(outside, templates_dir / "plan-template.md")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_refresh_shared_templates(project, invoke_separator=".", force=True)
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
"""Shared infra directory creation must not follow a symlinked .specify."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-dir-test"
project.mkdir()
outside = tmp_path / "outside-specify"
outside.mkdir()
os.symlink(outside, project / ".specify")
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
_install_shared_infra(project, "sh", force=True)
assert not (outside / "scripts").exists()
assert not (outside / "templates").exists()
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path):
"""Shared infra manifest saves must not follow destination symlinks."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "symlink-shared-manifest-test"
project.mkdir()
integrations_dir = project / ".specify" / "integrations"
integrations_dir.mkdir(parents=True)
outside = tmp_path / "outside-manifest.json"
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, integrations_dir / "speckit.manifest.json")
core_pack = tmp_path / "core-pack"
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
with pytest.raises(ValueError, match="symlinked integration manifest"):
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_template_refresh_preflights_before_writing(self, tmp_path):
"""Template refresh validates all destinations before writing any file."""
from specify_cli.shared_infra import refresh_shared_templates
project = tmp_path / "preflight-refresh-test"
project.mkdir()
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
core_pack = tmp_path / "core-pack"
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8")
(templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8")
existing = templates_dir / "a-template.md"
existing.write_text("# old a\n", encoding="utf-8")
outside = tmp_path / "outside-z.md"
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, templates_dir / "z-template.md")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
refresh_shared_templates(
project,
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
invoke_separator=".",
force=True,
)
assert existing.read_text(encoding="utf-8") == "# old a\n"
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
"""Full shared infra installs validate destinations before writing any file."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "preflight-install-test"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
core_pack = tmp_path / "core-pack"
scripts_src = core_pack / "scripts" / "bash"
scripts_src.mkdir(parents=True)
(scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8")
(scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8")
existing = scripts_dir / "a.sh"
existing.write_text("# old a\n", encoding="utf-8")
outside = tmp_path / "outside-z.sh"
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, scripts_dir / "z.sh")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
assert existing.read_text(encoding="utf-8") == "# old a\n"
assert outside.read_text(encoding="utf-8") == "# outside\n"
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
"""Nested script source files create safe destination parents at write time."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "nested-script-install-test"
project.mkdir()
core_pack = tmp_path / "core-pack"
nested_src = core_pack / "scripts" / "bash" / "nested"
nested_src.mkdir(parents=True)
(nested_src / "deep.sh").write_text("# nested\n", encoding="utf-8")
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
nested_dest = project / ".specify" / "scripts" / "bash" / "nested" / "deep.sh"
assert nested_dest.read_text(encoding="utf-8") == "# nested\n"
def test_shared_infra_skip_warning_uses_posix_paths(self, tmp_path):
"""Skipped shared infra paths are reported consistently across platforms."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "posix-skip-warning-test"
project.mkdir()
nested_dest = project / ".specify" / "scripts" / "bash" / "nested"
nested_dest.mkdir(parents=True)
(nested_dest / "deep.sh").write_text("# existing script\n", encoding="utf-8")
templates_dest = project / ".specify" / "templates"
templates_dest.mkdir(parents=True)
(templates_dest / "plan-template.md").write_text("# existing template\n", encoding="utf-8")
core_pack = tmp_path / "core-pack"
nested_src = core_pack / "scripts" / "bash" / "nested"
nested_src.mkdir(parents=True)
(nested_src / "deep.sh").write_text("# bundled script\n", encoding="utf-8")
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "plan-template.md").write_text("# bundled template\n", encoding="utf-8")
buffer = io.StringIO()
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=Console(file=buffer, force_terminal=False, width=120),
force=False,
)
output = buffer.getvalue()
assert ".specify/scripts/bash/nested/deep.sh" in output
assert ".specify/templates/plan-template.md" in output
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not stable on Windows")
def test_shared_template_writes_are_not_world_writable(self, tmp_path):
"""Shared template writes use a safe default mode instead of chmod 666."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "template-mode-test"
project.mkdir()
core_pack = tmp_path / "core-pack"
templates_src = core_pack / "templates"
templates_src.mkdir(parents=True)
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
written = project / ".specify" / "templates" / "plan-template.md"
assert written.stat().st_mode & 0o777 == 0o644
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
"""No skip warning when force=True (all files overwritten)."""
from specify_cli import _install_shared_infra
@@ -712,6 +1024,7 @@ class TestIntegrationCatalogDiscoveryCLI:
commands = [
["integration", "list"],
["integration", "install", "codex"],
["integration", "use", "codex"],
["integration", "uninstall"],
["integration", "switch", "codex"],
["integration", "upgrade"],
@@ -730,10 +1043,92 @@ class TestIntegrationCatalogDiscoveryCLI:
project.mkdir()
(project / ".specify").write_text("not a directory")
result = self._invoke(["integration", "list"], project)
commands = [
["integration", "list"],
["integration", "use", "codex"],
]
assert result.exit_code == 1, result.output
assert "Not a spec-kit project" in result.output
for command in commands:
result = self._invoke(command, project)
assert result.exit_code == 1, result.output
assert "Not a spec-kit project" in result.output
def test_project_scoped_commands_require_specify_directory(self, tmp_path):
project = tmp_path / "bad-feature-commands"
project.mkdir()
(project / ".specify").write_text("not a directory")
commands = [
["preset", "list"],
["preset", "add", "demo"],
["preset", "remove", "demo"],
["preset", "search"],
["preset", "resolve", "spec-template"],
["preset", "info", "demo"],
["preset", "set-priority", "demo", "5"],
["preset", "enable", "demo"],
["preset", "disable", "demo"],
["preset", "catalog", "list"],
["preset", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
["preset", "catalog", "remove", "demo"],
["extension", "list"],
["extension", "add", "demo"],
["extension", "remove", "demo"],
["extension", "search"],
["extension", "info", "demo"],
["extension", "update", "demo"],
["extension", "enable", "demo"],
["extension", "disable", "demo"],
["extension", "set-priority", "demo", "5"],
["extension", "catalog", "list"],
["extension", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
["extension", "catalog", "remove", "demo"],
["workflow", "run", "demo"],
["workflow", "resume", "demo"],
["workflow", "status"],
["workflow", "list"],
["workflow", "add", "demo"],
["workflow", "remove", "demo"],
["workflow", "search"],
["workflow", "info", "demo"],
["workflow", "catalog", "add", "https://example.com/catalog.yml"],
["workflow", "catalog", "remove", "0"],
]
for command in commands:
result = self._invoke(command, project)
failure_context = (
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
)
assert result.exit_code == 1, failure_context
assert "Not a spec-kit project" in result.output, failure_context
def test_catalog_config_output_uses_posix_paths(self, tmp_path):
project = self._make_project(tmp_path)
preset_add = self._invoke([
"preset", "catalog", "add",
"https://example.com/preset-catalog.yml",
"--name", "demo-presets",
], project)
assert preset_add.exit_code == 0, preset_add.output
assert "Config saved to .specify/preset-catalogs.yml" in preset_add.output
preset_list = self._invoke(["preset", "catalog", "list"], project)
assert preset_list.exit_code == 0, preset_list.output
assert "Config: .specify/preset-catalogs.yml" in preset_list.output
extension_add = self._invoke([
"extension", "catalog", "add",
"https://example.com/extension-catalog.yml",
"--name", "demo-extensions",
], project)
assert extension_add.exit_code == 0, extension_add.output
assert "Config saved to .specify/extension-catalogs.yml" in extension_add.output
extension_list = self._invoke(["extension", "catalog", "list"], project)
assert extension_list.exit_code == 0, extension_list.output
assert "Config: .specify/extension-catalogs.yml" in extension_list.output
# -- search ------------------------------------------------------------