feat(install): apm install <local-bundle> + lockfile-embedded plugin packs (#1098)#1099
feat(install): apm install <local-bundle> + lockfile-embedded plugin packs (#1098)#1099danielmeppiel merged 5 commits intomainfrom
Conversation
Implements the test-coverage plan for issue #1098 before any production code: 6 files, ~1,132 LOC of failing tests + plan doc. - tests/unit/bundle/test_local_bundle.py: detection, integrity, target-mismatch, plugin.json reader. - tests/unit/install/test_install_local_bundle.py: duck-type contract, rejected/allowed flag matrix, --as derivation, apm.yml guard. - tests/integration/test_install_local_bundle_e2e.py: round-trip, collision, dry-run, force, apm.yml side effects, air-gap sentinel (urllib + httpx + subprocess: git/gh). - tests/unit/bundle/test_plugin_exporter_lockfile.py: lockfile present in plugin output; pack.target and pack.bundle_files populated. - tests/unit/commands/test_unpack_deprecation.py: deprecation warning surface contract. - scripts/test-integration.sh: wire E2E into CI. Status: 60 skipped (module not yet created), 2 failed (lockfile in plugin export + deprecation), 1 passed (unpack baseline). Existing 7,410 tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
) Implement unified local-bundle install matching pip/cargo/npm: 'apm install ./bundle' or 'apm install bundle.tar.gz' now deploys plugin bundles directly into the project layout via the existing integration pipeline -- no need for 'apm unpack'. Plugin packs now embed apm.lock.yaml at the bundle root with a per-file SHA-256 manifest (pack.bundle_files) and the pack target list (pack.target). The lockfile in plugin bundles serves as SBOM/integrity proof, NOT as a routing manifest -- routing is delegated to the integration pipeline. New module: src/apm_cli/bundle/local_bundle.py - detect_local_bundle: identifies plugin bundles (dir or .tar.gz) - verify_bundle_integrity: SHA-256 verify against pack.bundle_files - check_target_mismatch: warn on cross-target install - LocalBundleInfo: frozen dataclass; tarballs use filter='data' New module: src/apm_cli/install/local_bundle_handler.py - install_local_bundle orchestrator extracted to honor the 1800-LOC architecture invariant on commands/install.py. CLI changes: - 'apm install <path>' early-exits to local-bundle handler when Path(packages[0]).exists() and detect_local_bundle returns truthy. - New '--as ALIAS' flag for slug override (precedence: --as > plugin.json:id > bundle dirname). - Rejected flags raise UsageError listing all offending options. Lockfile semantics: - Project apm.lock.yaml records local installs in local_deployed_files / local_deployed_file_hashes. - apm.yml is NOT mutated by local-bundle install (matches 'pip install ./foo.whl' semantics). Pack-side: - plugin_exporter.py walks bundle, computes per-file SHA-256, writes enriched apm.lock.yaml at bundle root before archiving. - lockfile_enrichment.enrich_lockfile_for_pack accepts optional bundle_files dict and pack target. Deprecation: 'apm unpack' marked [Deprecated] with redirect to 'apm install <bundle-path>'. Behavior unchanged in v0.12 (alias- soft-notice). Hard deprecation slated for v0.13. E2E coverage (tests/integration/test_install_local_bundle_e2e.py): - directory + tarball install - multi-target + auto-detect target - pack -> install round-trip fidelity - collision: managed/locally-modified, with and without --force - dry-run produces no filesystem mutation - apm.yml byte-for-byte unchanged - local lockfile records deployed files + SHA-256 hashes - air-gap: monkeypatches urllib/httpx/subprocess for git/gh, proves zero network I/O on local install Verification: - 7024 unit tests + 12 new E2E tests pass - ruff check + ruff format --check both silent - architecture invariant satisfied (commands/install.py 1743/1800) Closes #1098 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…IM11)
CRITICAL fixes:
- CR1: validate bundle_files keys against path traversal (verify+deploy paths)
and ensure each deploy destination resolves within the deploy_root.
- CR2: split tarfile.extractall() with filter='data' for Python 3.12+
(3.10/3.11 fall back to plain extract; argument is unsupported).
- CR3: switch DiagnosticCollector.warning() -> warn() (correct API name).
- CR4: replace 'if not scope:' (always False -- enum) with explicit
InstallScope.USER branch so global installs use the right deploy root.
- CR5: add InstallLogger fallback for collision warnings when diagnostics
collector is absent.
- CR6: materialize 6 TDD-stub unit tests (rejected/allowed flags, --as
alias derivation, apm.yml mutation guard, IM7/IM8 negative cases).
- CR7: document apm install <local-bundle> + --as flag in CLI reference,
deprecate apm unpack section in favour of install.
IMPORTANT fixes:
- IM1: verify_bundle_integrity() flags unlisted bundle files (allowing only
apm.lock.yaml and plugin.json).
- IM2: extend air-gap E2E patches with socket.create_connection / socket.socket.
- IM3: replace sys.exit(1) with click.Abort() in install_local_bundle handler.
- IM4: hash deployed file post-copy instead of trusting bundle's expected_hash.
- IM5: dry-run file listing uses logger.tree_item() so it surfaces in default mode.
- IM6: add 3 local-bundle examples to install command docstring.
- IM7: when path is a tarball but not a recognised bundle, raise UsageError
instead of silently falling through to the registry resolver.
- IM8: reject --as on registry installs (it is meaningful only for local bundles).
- IM10: add test_collision_managed_file_overwritten_with_force regression test.
- IM11: add test_plugin_export_lockfile_multi_target asserting pack.target
is a comma-joined STRING (canonical per lockfile_enrichment.py:175).
MINOR fixes:
- M-arch-1: add NOTE/TODO explaining why local-bundle deploy bypasses
integrate_package_primitives (deferred unification).
- M-cli-1: drop verbose-gate around verbose_detail (logger self-gates).
- M-cli-3: preserve dict insertion order for rejected-flags listing.
- M-scs-1: harden _normalize_hash to reject non-sha256 algo prefixes.
Verification:
- ruff check src/ tests/ + ruff format --check src/ tests/ -> silent.
- pytest tests/unit -> 7076 passed (no regressions).
- pytest tests/unit/bundle tests/unit/install
tests/integration/test_install_local_bundle_e2e.py -> 482 passed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds an offline, integrity-verified install path for previously packed plugin bundles by teaching apm install to detect and deploy local bundle directories/archives, and by embedding an enriched apm.lock.yaml (with pack.bundle_files) into plugin-format packs.
Changes:
- Route
apm install <ARG>to a new local-bundle install handler when<ARG>is a detected bundle (dir or.tar.gz/.tgz), including--assupport and consolidated rejected-flag validation. - Embed
apm.lock.yamlinto plugin bundles withpack.target+pack.bundle_files(sha256 manifest) for install-time integrity verification. - Deprecate
apm unpack, add extensive unit + integration coverage, wire the new integration test intoscripts/test-integration.sh, and update docs/changelog.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_plugin_exporter.py | Updates expectations/comments around plugin bundle contents now that apm.lock.yaml is embedded. |
| tests/unit/install/test_install_local_bundle.py | Unit coverage for install routing, rejected flags, and --as behavior. |
| tests/unit/commands/test_unpack_deprecation.py | Verifies apm unpack emits a deprecation warning and still functions. |
| tests/unit/bundle/test_plugin_exporter_lockfile.py | Pins apm.lock.yaml + pack.* metadata in plugin export output. |
| tests/unit/bundle/test_local_bundle.py | Unit coverage for bundle detection, integrity checks, and target mismatch warnings. |
| tests/unit/bundle/init.py | Establishes/adjusts unit-test package structure for bundle tests. |
| tests/integration/test_install_local_bundle_e2e.py | E2E coverage for offline local-bundle install (dir/tar), dry-run, collisions, and air-gap proof. |
| src/apm_cli/install/services.py | Adds integrate_local_bundle() imperative deploy loop and lockfile recording support. |
| src/apm_cli/install/local_bundle_handler.py | New orchestrator that validates flags, verifies integrity, resolves targets, deploys, and persists lockfile fields. |
| src/apm_cli/commands/pack.py | Marks apm unpack deprecated (help + runtime warning). |
| src/apm_cli/commands/install.py | Adds --as and early local-bundle detection/dispatch; rejects --as on non-bundle installs. |
| src/apm_cli/bundle/plugin_exporter.py | Computes per-file sha256 manifest and writes enriched lockfile into plugin bundle root. |
| src/apm_cli/bundle/lockfile_enrichment.py | Extends lockfile enrichment to include pack.bundle_files (deterministic ordering). |
| src/apm_cli/bundle/local_bundle.py | New local-bundle detection (dir/tar), integrity verification, and target mismatch warning helpers. |
| scripts/test-integration.sh | Adds the new local-bundle E2E test file to CI’s explicit integration test list. |
| packages/apm-guide/.apm/skills/apm-usage/commands.md | Updates command reference for local-bundle install + unpack deprecation. |
| docs/src/content/docs/reference/cli-commands.md | Documents --as and local-bundle install usage + apm unpack deprecation note. |
| CHANGELOG.md | Adds Unreleased entries for local-bundle install and lockfile-embedded plugin packs. |
Copilot's findings
Comments suppressed due to low confidence (2)
src/apm_cli/bundle/local_bundle.py:187
- The tar member safety check only rejects names starting with '/' or containing '..' path parts. This can miss other absolute-path forms (e.g., Windows drive paths) and backslash-separated traversal in crafted tar headers. Consider reusing
validate_path_segments(member.name, ...)(it normalizes backslashes and percent-decodes) and rejecting bothPurePosixPathandPureWindowsPathabsolute paths before extraction.
# Reject member symlinks/hardlinks and absolute / parent paths
# for safety (analogous to the pack-side filter).
for member in tar.getmembers():
if member.issym() or member.islnk():
return None
if member.name.startswith("/") or ".." in Path(member.name).parts:
return None
docs/src/content/docs/reference/cli-commands.md:231
- This example comment says
--asoverrides the "recorded slug", but the lockfile currently doesn't record a slug for local-bundle installs (onlylocal_deployed_files/hashes). Please update the example wording to avoid implying lockfile namespacing that doesn't exist yet.
# Deploy a local APM bundle (directory or .tar.gz produced by `apm pack`).
# Bundles are an imperative, air-gapped deploy: no apm.yml mutation,
# no network, no policy / MCP / dependency-resolver involvement.
apm install ./build/my-bundle
apm install ./my-bundle.tar.gz
apm install ./my-bundle --as custom-slug # override the recorded slug
- Files reviewed: 17/18 changed files
- Comments generated: 6
Address all 6 actionable findings from copilot-pull-request-reviewer on PR #1099, plus 1 low-confidence finding worth landing: - local_bundle.py: tighten tar member safety (use validate_path_segments to catch backslash traversal + percent-decoded forms; reject Windows drive-letter absolute paths via PureWindowsPath); ensure temp_dir is cleaned on every early-return path (was leaking on malicious archives). - install.py: --as help text now says 'Only valid for local-bundle installs' (matches actual UsageError behavior); example updated to 'custom log label' (alias only affects display, not lockfile). - docs/cli-commands.md: --as description and example clarified -- the alias is a log/display label, not lockfile namespacing. - packages/apm-guide/.../commands.md: tighten 'any existing path triggers local-bundle' wording -- only paths recognized as bundles (plugin.json at root) route to local-bundle mode; other paths still flow through the normal local-path dependency-resolver pipeline. - CHANGELOG: condense the two new entries to single concise sentences per Keep a Changelog convention. Adds regression test test_detect_cleans_temp_dir_on_malicious_archive to pin the tempdir-leak fix. Verification: - ruff check + ruff format --check: silent - 70 passed (was 69; +1 regression test) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
apm-action PR #31 compatibility assessmentShort answer: #31 is sufficient as a defensive measure on its own; once #1099 ships in apm 0.12, a follow-up apm-action change can additionally light up plugin-format restore and remove the current rejection. Why #31 already works after #1099 lands
The What #1099 unlocks for apm-action (follow-up, not blocking)The reasoning #31 cites for rejecting plugin restore -- "
So once a consumer's Recommended follow-up apm-action PR (post 0.12 release)Open after apm 0.12 ships:
TL;DR
|
- Add _looks_like_legacy_apm_bundle() to local_bundle.py to distinguish legacy --format apm tarballs (apm.lock.yaml present, plugin.json absent) from arbitrary non-bundle archives. - Improve install.py IM7 error path: when a tarball is a legacy apm-format bundle, emit a specific UsageError guiding the user to either repack with --format plugin or use apm unpack. Generic tarballs retain the existing error message. - Unit tests (test_local_bundle.py): 6 new tests covering detect_local_bundle returns None for legacy bundles, and _looks_like_legacy_apm_bundle correctly classifies legacy/plugin/junk tarballs and non-archives. - Unit tests (test_install_local_bundle.py): 1 new test asserting the install command rejects legacy apm-format tarballs with actionable error wording. - Integration tests (test_install_local_bundle_e2e.py): 2 new tests covering the legacy tarball rejection end-to-end and verifying that legacy directories (non-tarball) fall through to the resolver as expected.
feat(install):
apm install <local-bundle>+ lockfile-embedded plugin packsTL;DR
apm install <path/to/bundle>now restores a previously packed plugin bundle (directory or.tar.gz) without touching the network or the dependency resolver — matching thepip install ./wheel/cargo install --pathmental model. To make this safe by default,apm packnow embeds the project'sapm.lock.yamlinside the bundle with apack.bundle_files: path -> sha256manifest, so installs verify integrity and detect target mismatches before writing a single file.apm unpackis[Deprecated]and points at the unified entrypoint. Closes #1098.Note
Three maintainer-decision items are surfaced in Trade-offs below (pre-0.12 bundle handling,
--assemantics,integrate_package_primitivesdeviation). Flagged as deferrable, not blocking.Problem (WHY)
apm unpack; restoring a registry package requiredapm install. Same intent ("materialize this artifact onto disk"), different verbs — diverging from the package-manager convention every developer already has cached. The PROSE principle is explicit: "Favor small, chainable primitives over monolithic frameworks." — but small primitives only compose if their interface is consistent.apm.lock.yamlwas actively excluded from plugin bundles. That means the bundle on disk had no SBOM, no per-file integrity manifest, and no provenance trail — every downstreamunpackwas an unverified file copy.../../../etc/passwd) would happily be deployed by the existing copy loop. There was novalidate_path_segmentsguard at the install boundary.apm installhad a silent footgun. Passing a path that looked like a bundle (./bundle.tar.gz) fell through to the registry resolver, which would try to clone it asorg/repo. The error surfaced was about Git, not the user's actual intent.Approach (WHAT)
apm install <ARG>early-detects local bundles whenPath(ARG).exists()and routes to a newinstall_local_bundle()orchestrator. Registry path unchanged.apm pack(plugin format, the default) now embedsapm.lock.yamlat the bundle root with newpack.bundle_files(sha256 manifest) andpack.targetfields.src/apm_cli/bundle/local_bundle.pyprovides 4 pure functions:detect_local_bundle,verify_bundle_integrity,check_target_mismatch,read_bundle_plugin_json— each with two-gate path-traversal defense (validate_path_segments+ensure_path_within).--as ALIASflag for log namespacing on local installs; rejected withUsageErroron registry installs.apm unpackmarked[Deprecated], emits a one-line warning pointing atapm install <bundle-path>.local_deployed_files/local_deployed_file_hashesin the project lockfile;apm.ymlis never mutated.Implementation (HOW)
src/apm_cli/bundle/local_bundle.py(new, ~330 LOC) —LocalBundleInfodataclass plus the four pure functions above. Python 3.10/3.11 compat:tarfile.extractall(filter="data")is gated onsys.version_info >= (3, 12); older interpreters get manual symlink/abs-path/..-segment rejection as the primary gate. Strictsha256:hash-prefix validation (unknown algorithm prefixes raiseValueError).src/apm_cli/install/local_bundle_handler.py(new, ~180 LOC) — Orchestrator extracted to honor the 1800-LOC ceiling oncommands/install.py. Usesclick.Abort()(notsys.exit(1)) so Click owns the exit code. Dry-run usestree_itemto make the diff visible at default verbosity.src/apm_cli/install/services.py— Addsintegrate_local_bundle()(~150 LOC). Two critical correctness fixes landed here: (a)if not scope:was always False (enum members are truthy) → fixed toif scope == InstallScope.USER:so project-scope installs record relative paths, restoring portability andapm prune/apm uninstall; (b) deployed-file hashes now read from the destination file post-shutil.copy2, not the expected hash from source — defense-in-depth for any future content transform.src/apm_cli/bundle/plugin_exporter.py— Removedapm.lock.yamlfrom the per-package exclusion list and added a post-walk pass that sha256-hashes every file in the bundle, then writes the enriched lockfile to the bundle root before archiving.src/apm_cli/bundle/lockfile_enrichment.py—enrich_lockfile_for_packacceptsbundle_files: dict[str, str]and writes a canonical comma-joinedpack.targetstring.src/apm_cli/commands/install.py—--as ALIASClick option + early-exit branch; targetedUsageErrorfor tarball-but-not-bundle paths (bare directories still flow to the local-source-install path because that's a supported pre-existing form).src/apm_cli/commands/pack.py—apm unpackdocstring prefixed[Deprecated]; runtime warning via the existing logger.Diagrams
Legend: the install-time decision tree — every path either errors clearly, falls through to the unchanged registry flow, or runs verify-then-deploy. No silent fall-throughs.
flowchart LR U["apm install ARG"] --> D{"Path(ARG)<br/>exists?"} D -- "no" --> R["registry / git path<br/>(unchanged)"] D -- "yes" --> B{"plugin.json at<br/>bundle root?"} B -- "no, .tar.gz" --> E1["UsageError:<br/>not a valid bundle"] B -- "no, dir" --> R B -- "yes" --> V["verify_bundle_integrity<br/>(sha256 + path guards)"] V -- "fail" --> E2["Abort: integrity error"] V -- "ok" --> I["integrate_local_bundle<br/>copy + record paths"] I --> L["write project<br/>apm.lock.yaml"]Legend: pack-time SBOM embedding — the lockfile is generated, hashed against the bundle, and written into the bundle so the install side has something to verify against.
sequenceDiagram participant Author participant Pack as apm pack participant LE as lockfile_enrichment participant PE as plugin_exporter participant Bundle as plugin bundle Author->>Pack: apm pack (default = plugin) Pack->>PE: export_plugin_directory() PE->>PE: walk + sha256 every file PE->>LE: enrich_lockfile_for_pack(bundle_files, target) LE-->>PE: lockfile with pack.bundle_files + pack.target PE->>Bundle: write apm.lock.yaml at root PE->>Bundle: write plugin.json + skills/ + agents/ + ... Bundle-->>Author: ready to ship (SBOM embedded)Trade-offs
apm.lock.yamlin plugin bundles vs. separate sidecar. Chose embedded because (a) the bundle becomes self-describing (one artifact, one provenance file), (b) Claude Code's plugin format ignores unknown root files so there's no schema collision, and (c) it matches every other package format that ships its own manifest (PKG-INFO,.cargo_vcs_info.json,package.json). Rejected sidecar because two-file artifacts are easy to drift out of sync.apm.yml. Chose imperative — local installs record their footprint in the lockfile but never touch the declarative source of truth. Rejectedapm.ymlmutation because that would force the user's declared dependency list to absorb side-channel artifacts, breaking reproducibility.apm unpackdeprecated, not removed. One release of overlap so CI workflows can migrate. Removal scheduled for v0.13.pack.bundle_files) currently warn-and-proceed; design called for hard-reject. (2)--as ALIAScurrently surfaces only as a verbose log line — could be promoted to lockfile namespacing later. (3)integrate_local_bundledoes a raw file-copy loop; the architect's design called for routing throughintegrate_package_primitives. Documented asTODO(#1098-v0.13).Benefits
apm installis now the single restore primitive — registry, local bundle, or local source path. Removes the cognitive cost of remembering which command to use for which artifact.validate_path_segments+ensure_path_withinbefore any file is written.urllib,socket.create_connection,socket.socket,httpx,requests, andsubprocess— local installs do zero network I/O.apm packfollowed byapm install ./bundle.tar.gzis now a one-line restore in any GitHub Actions / Azure DevOps job.Validation
Lint (CI-mirror, must be silent) — silent:
Targeted test surface — 69 passed, no skips:
Two-round review-panel convergence (5 specialists in round 1, 2 specialists in round 2):
0e240941.pack.targetschema pinned.Scenario Evidence
tests/integration/test_install_local_bundle_e2e.py::TestLocalInstallRoundTriptests/integration/test_install_local_bundle_e2e.py::TestLocalInstallAirGaptests/unit/bundle/test_local_bundle.py::test_bundle_files_path_traversal_rejectedtests/unit/bundle/test_local_bundle.py::test_unlisted_bundle_file_flaggedtests/unit/bundle/test_local_bundle.py::test_bundle_files_absolute_path_rejected+ round-trip fidelityapm install ./bundle.tar.gzerrors clearly when the tarball isn't an APM bundle"tests/unit/install/test_install_local_bundle.py::test_invalid_tarball_raises_usage_error--as ALIASis rejected on registry installs"tests/unit/install/test_install_local_bundle.py::test_as_rejected_on_registry_installtests/unit/install/test_install_local_bundle.pyparametrized rejected-flags matrix (14 cells)apm packembeds a multi-target lockfile schema authors can rely on"tests/unit/bundle/test_plugin_exporter_lockfile.py::test_plugin_export_lockfile_multi_targetapm unpackwarns and points at the new entrypoint"tests/unit/commands/test_unpack_deprecation.pyFull pre-push verification transcript
How to test
apm pack --format plugin -o /tmp/myplugin→ confirm/tmp/myplugin/apm.lock.yamlexists with apack.bundle_filesmap.apm install /tmp/myplugin→ confirm files land in.claude-plugin/(or target dir) and projectapm.lock.yamlrecordslocal_deployed_files.apm pack --format plugin -o /tmp/myplugin.tar.gzthenapm install /tmp/myplugin.tar.gz→ same outcome.apm install ./does-not-exist.tar.gz→ ClickUsageErrorwith bundle-not-found wording (no Git clone attempt).apm unpack /tmp/myplugin.tar.gz→ still works, prints[Deprecated]warning pointing atapm install.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com