fix(install): deploy skills through home directory aliases - #2876
fix(install): deploy skills through home directory aliases#2876Daniel Meppiel (danielmeppiel) wants to merge 10 commits into
Conversation
Normalize the skill source root on a shallow metadata copy while preserving source-plan authorization and descendant symlink rejection. Add global audit and skill layout regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The new CHANGELOG entry references the closed issue number instead of ending with the PR number per the repo’s changelog format contract.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Lite
Findings: 2
New issues introduced by this change (2)
| Severity | Finding |
|---|---|
CHANGELOG.md — The new changelog bullet ends with #2867, but changelog entries are required to end with the pull… |
|
tests/unit/install/test_security_scan_scope.py — In the MARKETPLACE_PLUGIN branch the test writes .claude-plugin/plugin.json, but the subsequent… |
What changed in this PR
Fixes a global skill-install edge case where deploying skill files could be silently skipped when HOME/APM_HOME is a directory-symlink alias, by canonicalizing the package root spelling used during skill integration while preserving existing authorization and descendant-link protections.
Changes:
- Canonicalize
PackageInfo.install_pathon a shallow copy insideSkillIntegrator.integrate_package_skill()to align withDeployableSourcePlanroot resolution. - Add regression coverage for aliased home paths in the global install/audit integration test and add a multi-layout unit/component test ensuring authorization is preserved under root aliases.
- Document the root-alias behavior in CLI install docs and add an Unreleased changelog entry.
| File | Description |
|---|---|
| src/apm_cli/integration/skill_integrator.py | Shallow-copies package metadata and resolves the package root before skill routing/copy so source-plan authorization matches copy/discovery paths. |
| tests/unit/install/test_security_scan_scope.py | Adds a multi-layout test asserting root-alias normalization does not broaden authorization and still rejects symlink escapes. |
| tests/integration/test_global_audit_deploy_root.py | Adds a parametric regression test for global install + audit when HOME/APM_HOME uses a directory symlink alias. |
| docs/src/content/docs/reference/cli/install.md | Documents that global skill installation supports home directory alias spellings while keeping in-package symlink rejection unchanged. |
| CHANGELOG.md | Records the fix under Unreleased. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 1 | No substantive architectural concern. Root-only normalization preserves caller metadata, admission ordering, and existing source-plan authorization. |
| CLI Logging Expert | 0 | 0 | 0 | No CLI logging concerns: root-alias deployment is corrected without changing terminal output, flags, or environment configuration. |
| DevX UX Expert | 0 | 0 | 0 | Alias handling restores expected global install behavior without new flags or environment migration; both prior folds are present. No remaining DevX findings. |
| Supply Chain Security | 0 | 0 | 0 | Root-only normalization preserves source authorization, descendant-link rejection, caller metadata, and admission-first ordering. Both prior folds are present. |
| OSS Growth Hacker | 0 | 0 | 0 | Both reservations are satisfied: root aliases retain descendant/source restrictions, and release/install docs name HOME/APM_HOME support without flags or migration, with contributor credit. |
| Doc Writer | 0 | 0 | 0 | Install reference and CHANGELOG accurately describe root-alias support without implying broader package trust or environment migration. No remaining documentation findings. |
| Test Coverage | 0 | 0 | 0 | Both requested folds are present; filesystem and real-CLI lifecycle regression coverage address the alias fix. No remaining coverage gap found. |
| Performance Expert | 0 | 0 | 0 | Root-only normalization adds bounded metadata copying and path traversal per package, not per file. No actionable performance regression in the final six-file diff. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Architecture
classDiagram
direction LR
class BaseIntegrator {
<<BaseClass>>
}
class SkillIntegrator {
<<ConcreteIntegrator>>
+integrate_package_skill()
+skill_source_paths()
-_build_deployable_copy_ignore()
}
class PackageInfo {
<<MutableDataclass>>
+install_path Path
}
class DeployableSourcePlan {
<<ValueObject>>
+create()
+copy_ignore()
}
class SkillIntegrationResult {
<<Dataclass>>
+target_paths list
}
class services {
<<Module>>
+integrate_package_primitives()
}
class errors {
<<AdmissionBoundary>>
+enforce_agent_plugin_deployment_boundary()
}
BaseIntegrator <|-- SkillIntegrator
services ..> SkillIntegrator : dispatches
services ..> DeployableSourcePlan : creates
SkillIntegrator ..> errors : admission first
SkillIntegrator ..> PackageInfo : shallow copy and root resolve
SkillIntegrator ..> DeployableSourcePlan : consumes authorization
SkillIntegrator ..> SkillIntegrationResult : returns
note for SkillIntegrator "Base + subclass: existing specialized materializer"
note for DeployableSourcePlan "Dataclass-as-value-object: frozen source authorization plan"
note for PackageInfo "Only copied install_path changes; caller metadata is preserved"
class SkillIntegrator:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
CLI["[I/O] apm install --global<br/>src/apm_cli/commands/install.py: install()"]
PIPE["[I/O] Existing install pipeline<br/>install/services.py: integrate_package_primitives()<br/>DeployableSourcePlan.create() and pre-deploy scan"]
ADMIT{"integration/skill_integrator.py: integrate_package_skill()<br/>enforce_agent_plugin_deployment_boundary() FIRST"}
ERROR["agent_plugins/errors.py:<br/>admission exception before normalization"]
ROOT["[I/O] copy(package_info)<br/>copied install_path = install_path.resolve()<br/>caller metadata unchanged"]
ROUTE["[I/O] Unchanged routing and discovery<br/>should_install_skill(), skill_source_paths()<br/>native, bundle, and standalone sub-skill paths"]
COPY["[FS] Existing materialization: shutil.copytree()<br/>_build_deployable_copy_ignore() callback"]
AUTH{"[I/O] install/deployable_source_plan.py:<br/>DeployableSourcePlan.copy_ignore()<br/>_is_safe_source_path() and authorized membership"}
DENY["Ignore candidate: descendant symlink,<br/>containment escape, or absent authorization"]
PAYLOAD["[FS] Copy authorized SKILL.md and safe payload<br/>return SkillIntegrationResult.target_paths"]
DONE["commands/install.py: apply_install_command_outcome()<br/>ctx.exit(outcome.exit_code)<br/>tested successful global install: 0"]
CLI --> PIPE
PIPE -- existing approved skill path --> ADMIT
ADMIT -- denied --> ERROR
ADMIT -- admitted --> ROOT --> ROUTE
ROUTE -- deployable skills --> COPY --> AUTH
ROUTE -- existing skip or no-source result --> DONE
AUTH -- unsafe or unauthorized --> DENY
AUTH -- safe and authorized --> PAYLOAD
PAYLOAD -. remaining pipeline and finalization .-> DONE
Recommendation
I recommend shipping this bounded correction for maintainer consideration. Both earlier follow-ups are closed, and the final specialist returns identify no remaining actionable work. The parent retains the planned published-evidence refresh and hosted-CI verification; this recommendation does not certify either.
Full per-persona findings
Python Architect
- [nit] Keep the localized copy-and-normalize design; no additional abstraction is needed. at
src/apm_cli/integration/skill_integrator.py:1465
Design patterns - Used in this PR: Base + subclass -- SkillIntegrator retains BaseIntegrator collaboration while normalizing the package root once before skill-layout dispatch.
- Used in this PR: Dataclass-as-value-object -- the unchanged frozen DeployableSourcePlan remains the authorization input; shallow-copying mutable PackageInfo isolates the new install_path assignment from its caller.
- Pragmatic suggestion: none -- the current shape is the simplest correct design at this scope.
CLI Logging Expert
No findings.
DevX UX Expert
No findings.
Supply Chain Security
No findings.
OSS Growth Hacker
No findings.
Auth Expert -- inactive
Diff touches src/apm_cli/integration/skill_integrator.py (root-only copy/resolve) plus CHANGELOG and install docs and three tests; no changes to token management, AuthResolver, HostInfo, AuthContext, credential helpers, or remote host authentication.
Doc Writer
No findings.
Test Coverage
No findings.
Performance Expert
No findings.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Addresses the panel lifecycle follow-up and Copilot plugin fixture comment. Run global install and audit through the installed CLI with aliased HOME/APM_HOME while preserving physical-root snapshots and user-owned sentinels; pass the written plugin manifest explicitly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Shepherd driver: converged for maintainer reviewThe final full panel recommends Reservations carried from strategic-alignment
Folded in this run
Copilot signals reviewed
Regression-trap evidence (mutation-break gate)
Canonical-owner evidenceExact base/head detection reports the legacy plugin membership/placeholder owner through the integrator selector. Classification: Lint contractThe complete current canonical mirror passed before push: Ruff check/format on runtime, tests and architecture scripts; YAML I/O, 2100-line and portable-path guards; pylint R0801; auth signals and architecture boundaries. CIFinal-head CI passed, including both Linux shards, Windows Compatibility, lifecycle, binary smoke, architecture ratchets and lint. Merge Gate also passed. Full rollup: 17 successful checks and one intentional docs-deploy skip; zero CI recovery iterations. Mergeability status
ConvergenceOne outer iteration; two Copilot rounds; full initial and final specialist panels. No deferrals. This is a landing-ready advisory, not an approval or merge. No issue closure or merge was performed. |
Exercise connected global canonical and aliased command traces and deterministic spines inside generated models. Preserve global skill ownership and canonical module paths across update, validate frozen identity, and refresh mutable refs when no lock exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…imits Exercise deps info, cache info/prune, project target inspection, and global absolute-file find refusal without inventing unsupported flags or weakening durable-state checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Docs sync advisoryVerdict: no residual docs changes * Pages affected: 2 already updated * LLM calls: 3/15 At |
Bind frozen full-SHA validation to req-lk-003 and extend canonical/aliased trajectories with real lock exports and cache/source maintenance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Extend the existing identity owner selectors and provenance consumer checks for canonical module paths, frozen drift validation, and user-scope lockfile roots, with focused bypass mutations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…e-alias-lifecycle
Regenerate the conformance statements after adding the req-lk-003 frozen full-SHA equality/refusal test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

fix(install): complete skill deployment through home aliases across the lifecycle
TL;DR
Global skills now survive install, reinstall, update, and repair when
HOMEorAPM_HOMEis a directory alias. The connected revision-A/B lifecycle exposedadditional ownership, canonical-module-path, frozen-identity, and no-lock
freshness bugs, which this PR fixes rather than limiting the claim to install.
Real generated models now execute project, global-canonical, and global-aliased variants.
Closes #2867. Thanks to Dave Mead (@DaveMeadAdjust) for the reproduction and proposed fix.
Important
Keep this PR draft until the parent reviews the full obligations and hosted CI.
Local evidence below does not claim that hosted CI is green at the new head.
Native lifecycle-ledger inheritance and fresh provider acceptance remain pending.
No new flags, relaxed descendant-link checks, or global
findsupport are introduced.Problem (WHY)
as collisions, and then removed them during cleanup.
updating deployed skills could leave compilation reading revision A.
after removal could reuse stale mutable-ref cache state.
These are observed command/state failures, not hypothetical coverage claims.
The regression and mutation loop follows Agent Skills:
"do the work, run a validator (a script, a reference checklist, or a self-check), fix any issues, and repeat until validation passes."
Approach (WHAT)
DependencyReference.get_install_path()in download and integration.mutable refs when there is no lock to replay.
and run a mandatory transition spine inside each actual Hypothesis execution.
Implementation (HOW)
src/apm_cli/integration/skill_integrator.pysrc/apm_cli/install/phases/download.py,integrate.pysrc/apm_cli/install/plan.py,src/apm_cli/deps/tiered_ref_resolver.pytests/integration/test_required_lifecycle_state_machine.pytests/integration/test_generated_lifecycle_state_machine.pytests/utils/lifecycle_state.py,tests/integration/test_lifecycle_state_snapshot_contract.pytests/integration/test_ownership_invariant_lifecycle.py,test_ref_freshness_lifecycle.pytests/unit/install/test_plan.py,test_frozen.py,tests/unit/deps/test_tiered_ref_resolver.py,tests/integration/test_frozen_host_qualified_git_e2e.pytests/integration/test_global_audit_deploy_root.py,tests/unit/install/test_security_scan_scope.py.apm/architecture/owners/contracts-tooling.json,scripts/architecture_linter/checks/contracts_test_taxonomy.py,install_base_integrator_and_contraction.pytests/integration/test_architecture_dependency_reference.py,test_architecture_skill_provenance_scope.pytests/spec_conformance/test_lockfile_reqs.pyreq-lk-003, without a refactor waiver or a new normative requirement.CONFORMANCE.json,CONFORMANCE.mddocs/src/content/docs/reference/cli/install.md,packages/apm-guide/.apm/skills/apm-usage/commands.md,CHANGELOG.mdDiagrams
The connected trace advances installed state only on materializing transitions; the note identifies the newly asserted revision boundary.
stateDiagram-v2 direction LR [*] --> Empty Empty --> InstalledA: install A InstalledA --> InstalledA: reinstall / compile / lock InstalledA --> RemoteB: publish B RemoteB --> RemoteB: outdated / frozen replay A RemoteB --> InstalledB: update B / compile B note right of InstalledB NEW: bytes, refs and ownership advance together end note InstalledB --> InstalledB: frozen replay / drift refusal / reinstall InstalledB --> Faulted: inject audit faults Faulted --> InstalledB: restore / repair / audit InstalledB --> Removed: uninstall / audit Removed --> InstalledB: redeclare / install B Removed --> [*]Trade-offs
installrepairs it. Project prune preserves modified user files, with explicit collision/force-repair coverage.Benefits
Validation
Merged current
mainat1cab81dc6; final pushed head is171a3ecbe.Only generated conformance statements changed after
7ae103536; source, tests,scripts and owner registry are byte-identical. At
7ae103536, all eight deterministic/generated lifecycle witnesses,their rule catalog, lockfile conformance, and the merged Windows contract tests:
The skip is the existing publisher-timestamp SHOULD waiver, not a lifecycle
variant or the new frozen full-SHA test. Before the unrelated Windows-test-only
main merge, the complete required lifecycle family, generated family, lockfile
conformance and new owner guards at
1f917a150passed:Full spec suite at
171a3ecbe:203 passed, 2 skipped in 21.88s.Both skips are existing specification waivers; the new test passes.
Conformance statements regenerate with no diff. This repairs the sole failing
hosted check at
7ae103536; all other checks at that predecessor succeeded.Full canonical lint at
171a3ecbe: Ruff check/format, YAML I/O, 2100-line limit,portable-relative-path guard, pylint R0801, auth boundaries, and architecture
boundaries all exited zero. Mode B recognizes the real
req-lk-003conformancetest; orphan checking aligns all 122 requirements. Assertion and exact-duplicate
ratchets pass.
uv.lockis unchanged.mmdcrendered the diagram.The authored native contract assesses all 84 Click registrations individually
(22 applicable, including hidden
infoandlock export), with eight exactvariant witnesses and command-specific resource reasons for the rest. An actual
native-provider run passed all eight pytest nodes; corrected authored contexts
match its recorded trajectories. That diagnostic is not a fresh acceptance
receipt: provider integration/rerun and current hosted CI are still pending.
Executed transition matrix and command applicability
Gis the physical APM_HOME; command environment retains the lexical HOME/APM_HOMEalias in the aliased case.
Imeansinstall --global --no-policy --parallel-downloads 0.IA; repeatIAcompile --globalA;lock --global --no-policy --parallel-downloads 0lock export --globalA;lock export --global --format spdxBoutdated --global --parallel-checks 0 --verboseupdate --global --dry-run --parallel-downloads 0;I --frozenupdate --global --yes --parallel-downloads 0;compile --globalcompile --global --dry-run; compile again;I --dry-run;I --update;deps update --global --parallel-downloads 0lock --global --no-policy --parallel-downloads 0 --update;I --frozenaudit --ci --no-policy --no-fail-fast --format json, cwd Gcache clean --yes;deps clean --dry-run;deps clean --yes, cwd G; audit;Iuninstall --global <remote_url>; audit; redeclare;I; audit; uninstall; auditdeps list/tree/why --global;view <name> --global; hiddeninfo <name> --globaldeps info <name>, cwd Gcache info;cache prune --days 30targets --json, cwd callerfind <absolute-global-skill-path>, cwd Gpruneapprove,deny,policy,lifecycleinit,pack,unpack,publish,pluginmarketplace,search,mcp, remote-versionviewlist,preview,run,runtime,doctor,self-update,config,experimentalGenerated execution and mutation evidence
test_generated_lifecycle_sequences_preserve_reference_modelpasses separatelyfor
project,global-canonical, andglobal-aliased.run_state_machine_as_testuses bounded settings: 6 project / 3 each globalexamples, 8 tail steps, deterministic generation/shrinking. The initializer
executes
_mandatory_replayinside the actual Hypothesis run, not just a separate test.The separate three mandatory-replay cases and exact rule/property catalog also pass.
Global spine: dry-run, install, reinstall, compile, lock, installed readers,
both lock exports, publish, outdated, frozen A, update, compile B, both lock exports,
legacy update, frozen B, frozen refusal, reinstall, cache clean,
source clean/audit failure/rehydration, tamper,
audit-tampered, repair, audit-clean, uninstall, audit-empty, redeclare,
install B, audit-clean, uninstall, audit-empty. Project rules retain declaration
removal/prune and add modified-user-file collision/force-repair closure.
.resolve()skill_exists=False.# revision-bwhile compiled output still contains# revision-a.No mutations remain in the committed source.
Scenario Evidence
tests/integration/test_required_lifecycle_state_machine.py::test_required_global_audit_rule_matrix_for_external_roots(regression-trap for #2867)tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_sequences_preserve_reference_modeltests/integration/test_ownership_invariant_lifecycle.py::test_global_update_preserves_owned_external_skill_targetstests/integration/test_ref_freshness_lifecycle.py::test_frozen_default_ref_rehydrates_cold_cache_without_ref_drifttests/unit/install/test_security_scan_scope.py::test_package_skill_root_alias_preserves_authorization,tests/integration/test_lifecycle_state_snapshot_contract.pytests/spec_conformance/test_lockfile_reqs.py::test_frozen_manifest_pin_requires_the_exact_locked_commit(req-lk-003)How to test
APM_E2E_TESTS=1 APM_BINARY_PATH="$PWD/.venv/bin/apm"and runuv run --extra dev pytest -q tests/integration/test_generated_lifecycle_state_machine.py tests/integration/test_required_lifecycle_state_machine.py::test_required_global_audit_rule_matrix_for_external_roots.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com