Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- A warm-cache `apm install --frozen` keeps the commit a dependency was resolved from as the Codex source pin, instead of the `cached` sentinel that native admission rejected as an invalid pin. (#3388)

## [0.35.0] - 2026-10-22

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ def _check_codex_lifecycle(provider: FactsProvider) -> tuple[Violation, ...]:
("compute_native_bundle_hash", "src/apm_cli/utils/content_hash.py"),
("owns_codex_dependency", _CODEX_PREFIX + "registrar.py"),
("verify_codex_source_identity", _CODEX_PREFIX + "registrar.py"),
("is_codex_source_pin", _CODEX_PREFIX + "registrar.py"),
):
count = _count_across(
provider,
Expand Down Expand Up @@ -532,6 +533,7 @@ def _check_codex_lifecycle(provider: FactsProvider) -> tuple[Violation, ...]:
"ctx.package_deployed_files.get(key)",
),
"src/apm_cli/install/sources.py": (
"is_codex_source_pin(cached_commit)",
"is_codex_compatibility_manifest(install_path, detect_package_type(install_path)[1])",
"native_validation = validate_apm_package(install_path, source_path=install_path)",
),
Expand Down
14 changes: 9 additions & 5 deletions src/apm_cli/codex_plugins/registrar.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
from apm_cli.utils.path_security import ensure_path_within, has_symlink_component, safe_rmtree


def is_codex_source_pin(value: object) -> bool:
"""Return whether a value is a full commit identity, the only valid source pin."""
return (
isinstance(value, str) and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", value) is not None
)


def marketplace_name(modules_dir: Path, dependency_key: str) -> str:
"""Namespace an owned marketplace by APM home and canonical dependency identity."""
identity = f"{modules_dir.resolve().as_posix()}\0{dependency_key}".encode()
Expand Down Expand Up @@ -111,10 +118,7 @@ def read(cls, data: Any, root: Path) -> _Record:
or not isinstance(data.get("pending"), bool)
or (
data.get("source_commit") is not None
and (
not isinstance(data["source_commit"], str)
or re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", data["source_commit"]) is None
)
and (not is_codex_source_pin(data["source_commit"]))
)
or any(
data.get(key) is not None and not isinstance(data[key], str)
Expand Down Expand Up @@ -180,7 +184,7 @@ def verify_codex_source_identity(
records = _read_records(ledger_path_for(modules_dir))
record = records.get(dependency_key)
if source_commit is not None:
if re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", source_commit) is None:
if not is_codex_source_pin(source_commit):
raise CodexNativeError(
f"Invalid original Codex source pin for {dependency_key}; "
"restore the pinned dependency before retrying."
Expand Down
19 changes: 13 additions & 6 deletions src/apm_cli/install/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,13 +504,25 @@ def acquire(self) -> Materialization | None:
if not cached_package.source:
cached_package.source = dep_ref.repo_url

# Determine commit SHA for the cached path. See _resolve_cached_commit
# for the invariant ("recorded SHA must match disk identity") and the
# priority rules (PR #1158 -- branch-ref drift fix).
cached_commit = self._resolve_cached_commit()

from apm_cli.codex_plugins.registrar import is_codex_source_pin

# A cache hit without a live resolved reference still knows the commit
# the bytes came from, and downstream consumers read it from here: the
# Codex native path treats a resolved commit as a source pin, so the
# ``cached`` sentinel made a locked SHA look like an invalid pin (#3388).
resolved_or_cached_ref = (
resolved_ref
if resolved_ref
else ResolvedReference(
original_ref=dep_ref.reference or "default",
ref_type=GitReferenceType.BRANCH,
resolved_commit="cached",
# Short SHAs and branch names stay on the ``cached`` sentinel.
resolved_commit=cached_commit if is_codex_source_pin(cached_commit) else "cached",
ref_name=dep_ref.reference or "default",
)
)
Expand All @@ -530,11 +542,6 @@ def acquire(self) -> Materialization | None:
resolved_by = locked_parent_identity(ctx, node)
_is_dev = node.is_dev if node else False

# Determine commit SHA for the cached path. See _resolve_cached_commit
# for the invariant ("recorded SHA must match disk identity") and the
# priority rules (PR #1158 -- branch-ref drift fix).
cached_commit = self._resolve_cached_commit()

# Determine if cached package came from registry
_cached_registry = None
if (dep_locked_chk and dep_locked_chk.registry_prefix) or (
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/test_architecture_owner_rule_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ def test_native_typed_error_guard_retains_legacy_and_rejects_broad_handlers(
"record.source_commit == source_commit",
"False",
),
(
"src/apm_cli/install/sources.py",
"is_codex_source_pin(cached_commit)",
"bool(cached_commit)",
),
(
"src/apm_cli/install/drift.py",
"codex_records = verify_codex_source_identity(",
Expand Down
39 changes: 36 additions & 3 deletions tests/unit/install/test_sources_materialisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,8 @@ def test_with_targets_no_apm_yml(self, tmp_path: Path) -> None:
assert result is not None

def test_resolved_ref_none_creates_fallback_resolved_reference(self, tmp_path: Path) -> None:
"""resolved_ref=None → a synthetic ResolvedReference is built."""
"""resolved_ref=None → the fallback carries the locked commit, not 'cached'."""
locked_sha = "f1b2ba49d18a3de11f87d8e6dc672dfdaadbff85"
ctx = _make_ctx(targets=["copilot"])
dep_ref = _make_dep_ref(is_virtual=False, reference="feature-branch")
install_path = tmp_path / "pkg"
Expand All @@ -637,14 +638,46 @@ def test_resolved_ref_none_creates_fallback_resolved_reference(self, tmp_path: P
patch("apm_cli.utils.content_hash.compute_package_hash", return_value="h"),
patch(
"apm_cli.install.sources.CachedDependencySource._resolve_cached_commit",
return_value="c",
return_value=locked_sha,
),
):
source = self._make_source(ctx, dep_ref, install_path, resolved_ref=None)
result = source.acquire()

assert result is not None
# resolved_ref was None; the code builds a fallback - we just confirm no crash
assert result.package_info is not None
# A locked SHA must survive as the source pin; the Codex native path
# rejects the 'cached' sentinel as an invalid pin (#3388).
assert result.package_info.resolved_reference.resolved_commit == locked_sha

def test_resolved_ref_none_keeps_cached_sentinel_without_a_full_sha(
self, tmp_path: Path
) -> None:
"""A branch name is not a commit identity, so the sentinel stays."""
ctx = _make_ctx(targets=["copilot"])
dep_ref = _make_dep_ref(is_virtual=False, reference="main")
install_path = tmp_path / "pkg"
install_path.mkdir()
(install_path / "apm.yml").write_text("name: test\nversion: 0.0.1\n")

mock_pkg = MagicMock()
mock_pkg.source = "owner/repo"

with (
patch("apm_cli.models.apm_package.APMPackage.from_apm_yml", return_value=mock_pkg),
patch("apm_cli.models.validation.detect_package_type", return_value=(None, None)),
patch("apm_cli.utils.content_hash.compute_package_hash", return_value="h"),
patch(
"apm_cli.install.sources.CachedDependencySource._resolve_cached_commit",
return_value="main",
),
):
source = self._make_source(ctx, dep_ref, install_path, resolved_ref=None)
result = source.acquire()

assert result is not None
assert result.package_info is not None
assert result.package_info.resolved_reference.resolved_commit == "cached"

def test_unpinned_dep_sets_delta(self, tmp_path: Path) -> None:
"""dep_ref.reference=None → deltas['unpinned']=1."""
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_codex_native_registrar.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_preflight,
_Record,
_write_records,
is_codex_source_pin,
marketplace_name,
synchronize_codex_plugins,
verify_codex_source_identity,
Expand Down Expand Up @@ -332,3 +333,29 @@ def test_source_identity_records_are_cached_only_for_one_operation(tmp_path, mon
)
assert deployment.plugin.dependency_key in verify_codex_source_identity(modules, **identity)
assert reader.call_count == 2


@pytest.mark.parametrize(
("value", "expected"),
[
("a" * 40, True),
("a" * 64, True),
("a" * 7, False),
("A" * 40, False),
("main", False),
("cached", False),
(None, False),
],
)
def test_source_pin_predicate_is_shared_by_ledger_and_identity_checks(
tmp_path: Path, value: object, expected: bool
) -> None:
assert is_codex_source_pin(value) is expected
if isinstance(value, str) and not expected:
with pytest.raises(CodexNativeError, match="Invalid original Codex source pin"):
verify_codex_source_identity(
tmp_path / "modules",
dependency_key="owner/repo",
source_hash="sha256:" + "0" * 64,
source_commit=value,
)
Loading