[BUG] Symlinked .apm/agents silently deploys zero agents
@danielmeppiel is already working on this.
Since Sep 13, 2026.
- Dominant language
- Python
- Stars
- 3.9k
- Forks
- 365
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 132
Description
Describe the bug
When .apm/agents is a symlink to another directory inside the same package, apm install deploys zero agents. It exits 0 and prints nothing about agents, so the install looks like it succeeded. Skills from the same package deploy normally, which makes the failure hard to spot. The one route that escapes this is a local-path package install, because it dereferences the symlink before the filter runs.
DeployableSourcePlan._is_safe_source_path calls has_symlink_component, which rejects a candidate whose path crosses a symlink at any level (src/apm_cli/install/deployable_source_plan.py:27, src/apm_cli/utils/path_security.py:221). A symlinked .apm/agents directory therefore disqualifies every file under it. AgentIntegrator.find_agent_files filters its finds through that plan, gets an empty authorized set, and integrate_agents_for_target returns IntegrationResult(0, 0, 0, []) with no diagnostic.
flowchart TD
G1["apm install, git dependency"] --> G2["git checkout materializes<br/>.apm/agents as a symlink"]
L1["apm install, local path"] --> L2["local_content.py dereferences<br/>the symlink into a real file<br/>PR 1676, v0.19.0"]
G2 --> P["DeployableSourcePlan.create"]
L2 --> P
P --> S["_is_safe_source_path on<br/>.apm/agents/codebase-oracle.agent.md"]
S --> H["has_symlink_component<br/>walks every path component"]
H -->|"crosses a symlink, true"| DROP["file omitted from plan.paths"]
H -->|"no symlink, false"| KEEP["file added to plan.paths"]
DROP --> F["find_agent_files,<br/>filter_authorized_files returns empty"]
F --> Z["IntegrationResult zero files,<br/>no diagnostic, exit 0"]
KEEP --> Y["agents written to .claude/agents"]
This inverts a contract that #1668 settled a year ago. That report was the mirror image: a local-path install dropped symlinked skill references while the remote install kept them, and the expected behavior agreed on there was "a symlink whose target resolves inside the package is deployed as a regular file with the target's contents". #1676 (v0.19.0) fixed the local path to match the remote one, and that dereference is still in place at src/apm_cli/install/phases/local_content.py:277.
#2598 (v0.29.0) then added DeployableSourcePlan and has_symlink_component, and routed the integrators through it. The remote path has no dereference step, so Git-materialized symlinks now hit the new filter. The two install routes are asymmetric again, in the opposite direction, and the route that #1668 treated as the reference is the broken one.
Reach
Our bundles have shipped .apm/agents -> ../agents since May 2026. The authored files live in agents/ at the package root, next to skills/, and .apm/ holds the typed entry points APM reads. One of our projects locked with 0.14.2 lists 10 .claude/agents/*.md paths under deployed_files, five per dependency. The same dependencies locked with 0.29.1 list none, and .claude/agents/ is never created.
Skills are not symlink tolerant either, which is worth stating because it shapes the fix. A .apm/skills -> ../skills symlink is refused by the same predicate. Skills still deploy because a root-level skills/<name>/SKILL.md collection is a separately recognized layout (DeployableSourcePlan.create reads both skills/ and .apm/skills/), so APM never needs the symlinked path. Agents have only .apm/agents/ and loose *.agent.md at the package root, so they are the primitive that disappears.
flowchart LR
subgraph pkg["package as shipped"]
A1["agents/*.agent.md"]
S1["skills/name/SKILL.md"]
AL["symlink .apm/agents to ../agents"]
SL["symlink .apm/skills to ../skills"]
end
AL -->|"only source location<br/>APM reads for agents"| REJ1["rejected, symlink component"]
SL -->|"read for skills"| REJ2["rejected, symlink component"]
S1 -->|"root skills collection,<br/>a recognized layout"| OK["skills deploy"]
A1 -->|"no root-level agents layout exists"| NONE["never read"]
REJ1 --> GONE["zero agents deployed"]
NONE --> GONE
To Reproduce
Minimal case, no remote needed. A project whose own .apm/agents is a real directory deploys its agent:
mkdir -p probe-real/.claude probe-real/.apm/agents && cd probe-real
printf 'name: probe-real\nversion: 0.0.0\ndependencies:\n apm: []\n mcp: []\n' > apm.yml
printf -- '---\nname: repro-agent\ndescription: A repro agent.\nmodel: sonnet\n---\n\nBody.\n' > .apm/agents/repro-agent.agent.md
apm install -v
ls .claude/agents/ # repro-agent.md
Move the same file to a sibling directory and point .apm/agents at it, and nothing deploys:
mkdir -p probe-link/.claude probe-link/agents probe-link/.apm && cd probe-link
printf 'name: probe-link\nversion: 0.0.0\ndependencies:\n apm: []\n mcp: []\n' > apm.yml
printf -- '---\nname: repro-agent\ndescription: A repro agent.\nmodel: sonnet\n---\n\nBody.\n' > agents/repro-agent.agent.md
ln -s ../agents .apm/agents
apm install -v
ls .claude/agents/ # No such file or directory
The same thing happens for a Git dependency, which is how we hit it. Installing a bundle that ships .apm/agents -> ../agents produces no .claude/agents/ and no agent entries in apm.lock.yaml. Installing that identical, unmodified checkout with apm install <local path> deploys all six of its agents, and apm_modules/_local/<pkg>/.apm/agents is a real directory there rather than a symlink.
Expected behavior
The #1668 contract holds on every install route: a symlink resolving inside the package root is deployed as a regular file. _is_safe_source_path already calls ensure_path_within_resolved, which rejects the case the guard exists for, a link escaping the package. Two options, either would work for us:
- Dereference in-package symlinks for Git-sourced packages the way
local_content.pydoes for local paths, so the two routes agree. - Have
has_symlink_componentaccept a symlink whose resolved target stays under the package root, and keep rejecting the rest.
Failing that, the install has to say something. A package can lose every one of its agents while apm install reports success, and no output at any verbosity points at the cause. AgentIntegrator already carries a DiagnosticCollector and emits Skipped non-agent Markdown in agents source tree for files it classifies out, so this path has somewhere to put a message. It took us two sessions to find it by reading the installer source.
Environment
- OS: macOS 26.4.1 (Darwin 25.4.0, arm64)
- Python Version: 3.12 (bundled in the release binary)
- APM Version: 0.29.1 (1b3d80a).
_is_safe_source_pathis unchanged onmainat v0.30.0, so this is not fixed there.
Logs
Verbose install of the symlinked case. Nothing about agents at any point, and the plan walk visits no files:
[#] Perf: 3 walks, 0 file matches, 0 files visited, 0.001s total walk time
[#] Perf: .apm: 3 walk(s) (1ms, 0 files visited, 0 matched)
[#] Perf: discovery: 3 call(s) (1 unique base(s), 2 cache hit(s), 66%)
...
[*] Installed 1 APM dependency in 0.0s.
The real-directory case, same command:
Deployed 1 local primitive(s) from .apm/
[#] Perf: 3 walks, 1 file matches, 3 files visited, 0.001s total walk time
[#] Perf: .apm: 3 walk(s) (1ms, 3 files visited, 1 matched)
Additional context
We can restructure our bundles so .apm/agents is a real directory, and we will if the symlink is meant to be unsupported. Worth saying explicitly either way, because the current behavior reads as a package that installed fine.
Related: #1668 and #1676 (the contract and the local-path dereference), #2598 (added DeployableSourcePlan and has_symlink_component), #2710 (a different Git symlink case, dangling links in sparse-cone subdir installs).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.