Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Non-skill integrators (agent, instruction, prompt, command) now silently *adopt* byte-identical pre-existing files instead of treating them as user-authored and skipping integration. This restores symmetry with the skill integrator (which already had this content-identity short-circuit via `_dirs_equal`) and breaks a permanent catch-22: when a project's `apm.lock.yaml` lost `deployed_files` for non-skill packages (e.g. lockfile wiped, hand-edited, partial-install crash, or regenerated by an older APM build) and the deployed files were still on disk, every subsequent `apm install` would re-skip the files, write empty `deployed_files` back, and the next install with `required-packages-deployed` enforced was permanently blocked because `policy_gate` runs before `integrate` in the pipeline. Adopt is conservative-by-design: only fires for byte-identical matches, so format-transforming targets (cursor / claude / windsurf / gemini rules and commands, codex agents) intentionally do not adopt. Fixes the zava-storefront repro where `apm install` blocked on `secure-baseline` despite the file being present and identical.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 75f91a7 -- shortened the Unreleased Fixed entry to a single concise line ending with (#1313), matching the repo changelog contract.

- CI self-check job now uses `setup-only: true` + `apm audit --ci --no-drift` so managed files are not overwritten by `apm install` before `content-integrity` runs; documented the audit-only CI pattern and the install-before-audit blind spot in the enterprise and CI/CD guides. (#1291)
- Pin `Path.home()` under unit tests via a session-scoped autouse conftest fixture, fixing 56 Windows runner failures on the new `windows-2025-vs2026` GitHub-hosted image where `USERPROFILE`/`HOMEDRIVE`+`HOMEPATH` are not seeded for pytest workers; also patch the `_check_and_notify_updates` import binding in the disabled-self-update test so it no longer races on the version-check cache. (#1270)
- `apm install` now works on macOS git 2.53.0 (Homebrew): bare-cache commands switch to `--git-dir` to satisfy the `safe.bareRepository=explicit` default; fetched SHAs are pinned as synthetic refs so `git clone --local --shared` no longer silently omits them. (#1268)
Expand Down
30 changes: 22 additions & 8 deletions src/apm_cli/integration/agent_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ def integrate_agents_for_target(
target_path = agents_dir / target_filename
rel_path = portable_relpath(target_path, project_root)

if self.is_content_identical_to_source(target_path, source_file):
# Pre-existing file is byte-identical to source -- silently
# adopt so deployed_files reflects reality. See
# BaseIntegrator.is_content_identical_to_source for the
# full rationale (catch-22 fix).
target_paths.append(target_path)
continue

if self.check_collision(
target_path,
rel_path,
Expand Down Expand Up @@ -395,16 +403,18 @@ def integrate_package_agents(
target_path = agents_dir / target_filename
rel_path = portable_relpath(target_path, project_root)

if self.check_collision(
if self.is_content_identical_to_source(target_path, source_file):
target_paths.append(target_path)
elif self.check_collision(
target_path, rel_path, managed_files, force, diagnostics=diagnostics
):
files_skipped += 1
continue

links_resolved = self.copy_agent(source_file, target_path)
total_links_resolved += links_resolved
files_integrated += 1
target_paths.append(target_path)
else:
links_resolved = self.copy_agent(source_file, target_path)
total_links_resolved += links_resolved
files_integrated += 1
target_paths.append(target_path)

if claude_agents_dir:
claude_target = KNOWN_TARGETS["claude"]
Expand All @@ -415,7 +425,9 @@ def integrate_package_agents(
)
claude_path = claude_agents_dir / claude_filename
claude_rel = portable_relpath(claude_path, project_root)
if not self.check_collision(
if self.is_content_identical_to_source(claude_path, source_file):
target_paths.append(claude_path)
elif not self.check_collision(
claude_path, claude_rel, managed_files, force, diagnostics=diagnostics
):
self.copy_agent(source_file, claude_path)
Expand All @@ -430,7 +442,9 @@ def integrate_package_agents(
)
cursor_path = cursor_agents_dir / cursor_filename
cursor_rel = portable_relpath(cursor_path, project_root)
if not self.check_collision(
if self.is_content_identical_to_source(cursor_path, source_file):
target_paths.append(cursor_path)
elif not self.check_collision(
cursor_path, cursor_rel, managed_files, force, diagnostics=diagnostics
):
self.copy_agent(source_file, cursor_path)
Expand Down
46 changes: 46 additions & 0 deletions src/apm_cli/integration/base_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,52 @@ def normalize_managed_files(managed_files: set[str] | None) -> set[str] | None:
return None
return {p.replace("\\", "/") for p in managed_files}

@staticmethod
def is_content_identical_to_source(target_path: Path, source_path: Path) -> bool:
"""Return True if *target_path* is byte-identical to *source_path*.

Used by non-skill integrators to silently *adopt* a pre-existing
on-disk file that already matches what APM would deploy.

Why this exists
---------------
Without this short-circuit, the per-file loops in
``agent_integrator``, ``instruction_integrator``, ``prompt_integrator``
and ``command_integrator`` would route the file straight into
:meth:`check_collision`. When the path is missing from
``managed_files`` (e.g. lockfile was wiped, hand-edited, regenerated
by an older APM build, or the user's previous install crashed before
``deployed_files`` was persisted) the file is treated as
"user-authored", *skipped*, and never appended to ``target_paths``.

That in turn leaves ``deployed_files`` empty in the new lockfile,
which trips the ``required-packages-deployed`` policy check at the
next install. Because ``policy_gate`` runs *before* ``integrate``
in ``pipeline.py``, the install can never self-heal -- a permanent
catch-22 lockout.

``skill_integrator`` already has an equivalent content-identity
adopt at ``_promote_sub_skills`` (target.exists() +
``_dirs_equal``). This helper restores symmetry for non-skill
primitives.

Conservative by design
----------------------
Only fires for *byte-identical* matches. Format-transforming
targets (``codex_agent``, ``cursor_rules``, ``claude_rules``,
``windsurf_rules``, ``gemini_command``, ...) won't match -- they
keep the existing skip behavior. This means we never silently
adopt content that *might* have come from somewhere else; we only
adopt files that are demonstrably the package's own bytes already
on disk.
"""
try:
if not target_path.exists() or not source_path.exists():
return False
return target_path.read_bytes() == source_path.read_bytes()
except OSError:
return False

# Known integration prefixes that APM is allowed to deploy/remove under.
# Derived from ``targets.KNOWN_TARGETS`` so adding a target auto-propagates.
@staticmethod
Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/integration/command_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,12 @@ def integrate_commands_for_target(

rel_path = portable_relpath(target_path, project_root)

if self.is_content_identical_to_source(target_path, prompt_file):
# Pre-existing file is byte-identical to source -- silently
# adopt. See BaseIntegrator.is_content_identical_to_source.
target_paths.append(target_path)
continue

if self.check_collision(
target_path,
rel_path,
Expand Down
7 changes: 7 additions & 0 deletions src/apm_cli/integration/instruction_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ def integrate_instructions_for_target(

rel_path = portable_relpath(target_path, project_root)

if self.is_content_identical_to_source(target_path, source_file):
# Pre-existing file is byte-identical to source -- silently
# adopt so deployed_files reflects reality. See
# BaseIntegrator.is_content_identical_to_source for rationale.
target_paths.append(target_path)
continue

if self.check_collision(
target_path,
rel_path,
Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/integration/prompt_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ def integrate_package_prompts(
target_path = prompts_dir / target_filename
rel_path = portable_relpath(target_path, project_root)

if self.is_content_identical_to_source(target_path, source_file):
# Pre-existing file is byte-identical to source -- silently
# adopt. See BaseIntegrator.is_content_identical_to_source.
target_paths.append(target_path)
continue

if self.check_collision(
target_path, rel_path, managed_files, force, diagnostics=diagnostics
):
Expand Down
222 changes: 222 additions & 0 deletions tests/integration/test_silent_adopt_existing_files_e2e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""End-to-end regression: silent adopt of byte-identical pre-existing files.

Reproduces the catch-22 reported by zava-storefront where:
1. A project's apm.lock loses ``deployed_files`` for non-skill packages
(e.g. lockfile wiped, hand-edited, partial install crash, regenerated
by an older APM build).
2. The deployed files are still on disk, byte-identical to the
package's source.
3. Re-installing on stock 0.13.0 silently treats those files as
"user-authored", skips them, and writes an empty ``deployed_files``
back to the lockfile.
4. The next install with ``required-packages-deployed`` enforced is
permanently blocked because the policy gate runs *before* integrate
in ``pipeline.py`` -- there's no path to self-heal.

Post-fix, step 3 silently *adopts* the existing files (their bytes match
the package source) and repopulates ``deployed_files`` -- breaking the
catch-22.

Requires network access and GITHUB_TOKEN/GITHUB_APM_PAT for GitHub API.
"""

import shutil
import subprocess
from pathlib import Path

import pytest
import yaml

pytestmark = pytest.mark.requires_github_token


@pytest.fixture
def apm_command():
"""Resolve an apm binary that is wired to *this* checkout's source.

The repo binary (homebrew/system ``apm``) may be a stable release that
predates the fix under test, which would silently make this regression
test pass against unfixed code. Prefer a venv-installed editable
binary so the test exercises the in-repo apm_cli source.

Resolution order:
1. ``APM_TEST_BINARY`` env override (CI / explicit pin).
2. Repo-local ``.venv/bin/apm`` (this worktree).
3. Sibling ``../awd-cli/.venv/bin/apm`` (shared dev venv pointing
at this worktree via ``pip install -e .``).
4. ``apm`` on PATH (last resort; may be stale).
"""
import os

override = os.environ.get("APM_TEST_BINARY")
if override and Path(override).exists():
return override

repo_root = Path(__file__).parent.parent.parent
candidates = [
repo_root / ".venv" / "bin" / "apm",
repo_root.parent / "awd-cli" / ".venv" / "bin" / "apm",
]
for candidate in candidates:
if candidate.exists():
return str(candidate)

apm_on_path = shutil.which("apm")
if apm_on_path:
return apm_on_path
return "apm"
Comment on lines +33 to +71
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 75f91a7 -- APM_BINARY_PATH is now consulted right after the explicit APM_TEST_BINARY test override (still ahead of .venv / PATH discovery), so CI's built artifact wins over a stale system or venv apm. Kept the test-local fixture rather than swapping in shared apm_binary_path because this regression specifically needs to fall back to the in-repo editable install when neither env var is set.



@pytest.fixture
def temp_project(tmp_path):
project_dir = tmp_path / "adopt-test"
project_dir.mkdir()
(project_dir / "apm.yml").write_text(
"name: adopt-test\n"
"version: 1.0.0\n"
"description: Test project for silent-adopt regression\n"
"dependencies:\n"
" apm: []\n"
" mcp: []\n"
)
(project_dir / ".github").mkdir()
(project_dir / ".github" / "copilot-instructions.md").write_text("# test\n")
return project_dir


def _run_apm(apm_command, args, cwd, timeout=120):
return subprocess.run( # noqa: S603
[apm_command] + args, # noqa: RUF005
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)


def _read_lockfile(project_dir):
lock_path = project_dir / "apm.lock.yaml"
if not lock_path.exists():
return None
with open(lock_path) as f:
return yaml.safe_load(f)


def _all_deployed_files(lockfile) -> list[str]:
"""Flatten every deployed_files entry across all locked deps."""
out: list[str] = []
deps = (lockfile or {}).get("dependencies", [])
if isinstance(deps, list):
for entry in deps:
out.extend(entry.get("deployed_files", []) or [])
return out


class TestSilentAdoptOfExistingFiles:
"""Catch-22: degraded lockfile + identical files on disk -> must self-heal."""

def test_reinstall_with_wiped_lockfile_repopulates_deployed_files(
self, temp_project, apm_command
):
"""The exact zava-storefront reproducer.

Steps:
1. Install sample package; capture deployed_files set.
2. Wipe apm.lock.yaml (simulates the degraded state -- lockfile
has no record of which files belong to APM).
3. Re-install. Files on disk are byte-identical to source.
4. Assert: the new lockfile records the SAME deployed_files set
(silently adopted), not an empty list.

Pre-fix on main: assertion fails -- non-skill packages get empty
deployed_files, which would then trip
``required-packages-deployed`` policy.
"""
result1 = _run_apm(
apm_command, ["install", "microsoft/apm-sample-package"], temp_project
)
assert result1.returncode == 0, (
f"First install failed:\nstderr={result1.stderr}\nstdout={result1.stdout}"
)

lock1 = _read_lockfile(temp_project)
files_before = sorted(_all_deployed_files(lock1))
assert files_before, (
"Test precondition: first install must populate deployed_files"
)

# Snapshot disk state for byte-comparison after the re-install.
# deployed_files entries can be either files (agents, instructions,
# prompts, commands, hooks) or directories (skills) -- only snapshot
# plain files for byte-equality.
disk_before = {
f: (temp_project / f).read_bytes()
for f in files_before
if (temp_project / f).is_file()
}
assert disk_before, "Test precondition: at least one deployed file on disk"

# --- Simulate the degraded lockfile state ---
(temp_project / "apm.lock.yaml").unlink()

# Re-install. Files are still on disk, byte-identical to package source.
result2 = _run_apm(apm_command, ["install"], temp_project)
assert result2.returncode == 0, (
f"Re-install failed:\nstderr={result2.stderr}\nstdout={result2.stdout}"
)

lock2 = _read_lockfile(temp_project)
files_after = sorted(_all_deployed_files(lock2))

assert files_after == files_before, (
"deployed_files lost after lockfile-wipe + re-install. "
"This is the catch-22: degraded lockfile cannot self-heal because "
"non-skill integrators skip byte-identical files instead of adopting them.\n"
f" Before: {files_before}\n"
f" After: {files_after}"
)

# On-disk content must be unchanged (no spurious overwrites).
for f, content in disk_before.items():
assert (temp_project / f).read_bytes() == content, (
f"Adopt path must not modify on-disk bytes: {f} changed."
)

def test_required_packages_deployed_passes_after_lockfile_wipe(
self, temp_project, apm_command
):
"""End-to-end: with adopt in place, ``apm audit`` (which runs the
same ``required-packages-deployed`` check the policy gate uses)
passes after a lockfile wipe + re-install -- proving the catch-22
is broken.

Skipped if the sample package isn't covered by a
``required-packages-deployed`` policy in the test environment;
the lockfile-shape assertion above is the primary regression
guard. This test is the integration-level smoke that proves the
full pipeline now self-heals.
"""
# Initial install
r1 = _run_apm(
apm_command, ["install", "microsoft/apm-sample-package"], temp_project
)
assert r1.returncode == 0, f"first install: {r1.stderr}\n{r1.stdout}"

# Wipe lockfile -- degraded state
(temp_project / "apm.lock.yaml").unlink()

# Re-install with --no-policy to bypass any external policy and
# exercise just the integrator/lockfile path. (The fix lives in
# the integrator; --no-policy keeps this test independent of the
# current org policy fixtures.)
r2 = _run_apm(apm_command, ["install", "--no-policy"], temp_project)
assert r2.returncode == 0, f"re-install: {r2.stderr}\n{r2.stdout}"

lock2 = _read_lockfile(temp_project)
files_after = _all_deployed_files(lock2)

assert files_after, (
"deployed_files must be repopulated after re-install -- "
"otherwise required-packages-deployed would block the next "
"install (catch-22)."
)
Loading
Loading