Skip to content

Commit d1eec54

Browse files
danielmeppielDaniel MeppielCopilot
authored andcommitted
fix: target-agnostic local-bundle install (#1207) (#1217)
* fix: target-agnostic local-bundle install (#1207) apm pack no longer hardcodes pack.target: copilot. Bundles now ship in the Anthropic plugin layout as a transport convention only -- the consumer target is resolved by apm install from project context and primitives are routed per the resolved target's layout. Defects fixed (per #1207 RCA): - D1: pack records pack.target = "all" by default (or the auto-detected target). The flag remains for back-compat but is no longer the authoritative binding. - D2.a: plugin.json is skipped on install regardless of casing in the bundle manifest. .mcp.json is also skipped from the verbatim deploy loop -- MCP wiring is surfaced as a follow-up notice. - D2.b: instructions/*.md for compile-only targets (opencode, codex, gemini) are now staged under apm_modules/<slug>/.apm/instructions/ so apm compile picks them up. Slug is validated through validate_path_segments and the destination is checked with ensure_path_within to block traversal escapes. - D3: the local-bundle install path sets summary_rendered = True before returning so the outer finally-block no longer prints a misleading "Install interrupted" line on success. check_target_mismatch returns None when the bundle records "all", so target-agnostic bundles never warn against any consumer layout. Tests: - tests/unit/install/test_install_local_bundle_issue1207.py (13 cases) - tests/integration/test_install_local_bundle_e2e.py TestInstallLocalBundleIssue1207: 7-row pack -> install matrix over copilot, claude, cursor, opencode, codex, gemini plus a multi-target consumer asserting both native and staged deployment paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: stamp CHANGELOG with PR #1217 * Address panel review findings for #1207 - Case-insensitive .mcp.json skip in deploy loop and fallback walk (services.py) so case-folding filesystems cannot smuggle a renamed MCP file past the skip. - Tighten staged_instructions disjunct in local_bundle_handler.py (drop dead first disjunct that always triggered for compile-only targets) and case-insensitive bundle_mcp detection. - Compile hint now names the resolved target and primitive count. - "Share with: apm install <bundle>" success line in apm pack output. - Unit test class TestMcpJsonNeverDeployed covers case variants. - e2e compile-hint assertion uses whitespace-collapsed output to survive logger line-wrap. - Docs sweep: * pack-distribute.md: rewrote "Targeting mental model" as target-agnostic; removed --target examples; simplified cross- target -> plugin layout normalization. * cli-commands.md: marked apm pack --target deprecated; updated bundle install section; removed target-filter table. * lockfile-spec.md: pack.target is deprecated/optional informational metadata; bundle_files documented. * enterprise/security.md: added apm_modules/ allowed prefix with slug-validation note; new "Local bundle install trust model" subsection. * apm-usage skill (commands.md, workflow.md): pack.target is informational; OpenCode added to compile-needed list; new "Local bundle install" subsection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Iterate on panel review: harden slug validation + cleanup Address apm-review-panel findings on PR #1217: - sec-1: Enforce documented [A-Za-z0-9._-] slug whitelist in install/services.py before validate_path_segments, with explicit rejection of leading/trailing dots, '..', forward slashes, null bytes, and whitespace. Aligns code with security.md trust model. - sec-3: Add parameterized adversarial slug test cases (forward slash, null byte, leading/trailing dot, '@', whitespace). - py-arch-2: Hoist plugin.json / .mcp.json case-insensitive metadata skip above the per-target deploy loop so multi-target installs do not inflate the skipped counter once per target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #1217 review: ASCII slug, MCP wiring, doc audit - ASCII-only slug whitelist - Preserve nested instruction subdirs in stage_root - Pack success line mentions embedded apm.lock.yaml - Strengthened plugin.json leak assertion (whole-tree walk) - URL trailing-dot fix in security/marketplace error messages - Auto-wire bundle .mcp.json through MCPIntegrator (multi-target) so non-Claude harnesses get servers in their native MCP config - New TestBundleMcpWiring unit suite (parse + wire helpers) - Doc audit: pack-distribute, lockfile-spec, plugins, cli-commands, first-package, security, CHANGELOG Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * tests: add e2e coverage for bundle .mcp.json -> MCPIntegrator boundary Four e2e cases covering the wiring path that was previously only covered at the unit level: - bundle .mcp.json reaches MCPIntegrator.install with the bundle's servers and the resolved-target CSV passed via explicit_target - bundle without .mcp.json does not invoke the integrator (no spurious 'No MCP dependencies found' warnings on every install) - integrator failure does not break the install (file deploys are not undone by an MCP wiring hiccup) - dry-run never fires the integrator (zero side effects on the consumer's MCP config) Per-target file writes (Claude project .mcp.json, .vscode/mcp.json, .cursor/mcp.json) are owned by MCPIntegrator's own suite; testing them here would require installed runtime binaries on the CI host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: fix broken anchor #authoring-workflow -> #plugin-authoring Deploy Docs CI fails link validation; the actual heading is 'Plugin authoring' at the top of guides/plugins.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Daniel Meppiel <[email protected]>
1 parent dd6d23f commit d1eec54

17 files changed

Lines changed: 1380 additions & 112 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- `apm install` now accepts the YAML list form under `target:` (e.g. `target: [copilot, claude]`); previously crashed with a garbled `Unknown target` error. (#1197)
2323
- `apm install --update` now falls back from a stale `ADO_APM_PAT` to an `az login` AAD bearer in the preflight auth probe, matching the behavior of `apm install` and every other ADO call site. Previously the preflight raised `AuthenticationError` on 401/403 even when `az login` would have succeeded. The bearer env also pops any pre-existing `GIT_TOKEN` so the JWT flows only via `GIT_CONFIG_VALUE_0`, and the per-host stale-PAT warning dedup is lock-guarded so parallel installs against the same ADO host emit one warning instead of one-per-thread. (#1212)
2424
- `Unknown target` error suggestions no longer advertise the `agent-skills` meta-target, which `apm targets` intentionally omits from its table. The canonical set still accepts `agent-skills` via `--target` and `apm.yml`, but the recovery path printed on errors now matches what the discovery command actually lists. (#1215)
25+
- `apm pack` no longer hardcodes `pack.target` into bundles; bundles are target-agnostic and `apm install <bundle>` resolves the consumer target from project context and wires bundle `.mcp.json` servers per target via `MCPIntegrator`. (#1217)
2526

2627
## [0.12.4] - 2026-05-07
2728

docs/src/content/docs/enterprise/security.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,20 @@ APM deploys files only to controlled subdirectories within the project root.
190190
All deploy paths are validated before any file operation:
191191

192192
1. **No `..` segments.** Any path containing `..` is rejected outright.
193-
2. **Allowed prefixes only.** Paths must start with an allowed prefix (`.github/`, `.claude/`, `.cursor/`, or `.opencode/`).
193+
2. **Allowed prefixes only.** Paths must start with an allowed target-integrator prefix (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`, `.agents/`). In addition, the local-bundle install path stages instructions for compile-only targets under `apm_modules/<slug>/.apm/instructions/` with its own containment check (the resolved path must remain within `apm_modules/`) and `<slug>` validation rejecting traversal sequences and characters outside `[A-Za-z0-9._-]`.
194194
3. **Resolution containment.** The fully resolved path must remain within the project root directory.
195195

196196
A path must pass all three checks. Failure on any check prevents the file from being written.
197197

198+
### Local bundle install trust model
199+
200+
`apm install <bundle>` accepts a directory or `.tar.gz` produced by `apm pack`. Bundles are imperative (no policy / dependency-resolver / network) and target-agnostic; the consumer's project drives where files land. Trust boundaries:
201+
202+
1. **`bundle_files` keys are untrusted.** They come from the bundle's own `apm.lock.yaml` and are validated for traversal sequences before any filesystem path is constructed; resolved destinations must remain within the deploy root. Unsafe entries are skipped with a warning.
203+
2. **`plugin.json` is bundle metadata, never deployed.** It is recognized case-insensitively and skipped in both the manifest-driven deploy loop and the lockfile-less fallback walk so case-folding filesystems (HFS+, NTFS) cannot smuggle a renamed file past the skip.
204+
3. **`.mcp.json` is bundle metadata, never deployed verbatim.** It is recognized case-insensitively and skipped from the deploy loop. After files deploy, `apm install` parses the bundle's `.mcp.json` (Anthropic plugin schema, `mcpServers` map) and routes each entry through `MCPIntegrator.install` as a self-defined dependency, so the consumer's resolved target(s) get the servers in their own native MCP config (Claude `.mcp.json`, Copilot `~/.copilot/mcp-config.json`, VS Code `.vscode/mcp.json`, Cursor `.cursor/mcp.json`, etc.). `MCPIntegrator` enforces the same validation and runtime gating used by `apm.yml`-declared servers; per-server parse errors are isolated and do not block the rest of the install.
205+
4. **Slug validation.** The bundle's `id` (used as `<slug>` for staged instructions and the install label) is rejected if it contains traversal sequences or characters outside `[A-Za-z0-9._-]`.
206+
198207
### Symlink handling
199208

200209
Symlinks are never followed during file discovery or artifact operations:

docs/src/content/docs/getting-started/first-package.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ Output (plugin format is the default):
272272
```
273273
build/team-skills-1.0.0/
274274
+-- plugin.json # synthesized, schema-conformant per https://json.schemastore.org/claude-code-plugin.json
275+
+-- apm.lock.yaml # enriched copy with bundle_files manifest (used by `apm install <bundle>` for integrity)
275276
+-- agents/
276277
| +-- team-reviewer.agent.md
277278
+-- skills/

docs/src/content/docs/guides/pack-distribute.md

Lines changed: 29 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,9 @@ The left side (install, pack) runs where APM is available. The right side (downl
3636
Creates a self-contained bundle from installed dependencies. Reads the `deployed_files` manifest in `apm.lock.yaml` as the source of truth -- it does not scan the disk.
3737

3838
```bash
39-
# Default: Claude Code plugin directory, target auto-detected
39+
# Default: target-agnostic plugin bundle that installs into any consumer
4040
apm pack
4141

42-
# Filter by target
43-
apm pack --target copilot # only .github/ files
44-
apm pack --target claude # only .claude/ files
45-
apm pack --target all # all targets
46-
apm pack -t claude,copilot # multiple targets (comma-separated)
47-
4842
# Legacy APM bundle layout (consumed by microsoft/apm-action restore)
4943
apm pack --format apm
5044

@@ -63,61 +57,42 @@ apm pack --dry-run
6357
| Flag | Default | Description |
6458
|------|---------|-------------|
6559
| `--format` | `plugin` | Bundle format. `plugin` emits a Claude Code plugin directory with `plugin.json`. `apm` emits the legacy APM bundle layout. |
66-
| `-t, --target` | auto-detect | File filter: `copilot`, `claude`, `cursor`, `opencode`, `all`. `vscode` is a deprecated alias for `copilot`. |
60+
| `-t, --target` | (deprecated) | Deprecated. Emits a warning; the value is recorded in `pack.target` as diagnostic metadata only and is ignored by `apm install` target resolution. Bundles are target-agnostic; the consumer's project decides where files land at install time. |
6761
| `--archive` | off | Produce `.tar.gz` instead of directory |
6862
| `-o, --output` | `./build` | Output directory |
6963
| `--dry-run` | off | List files without writing |
7064
| `--force` | off | On collision (plugin format), last writer wins |
7165

72-
### Target filtering
73-
74-
The target flag controls which deployed files are included based on path prefix:
75-
76-
| Target | Includes |
77-
|--------|----------|
78-
| `copilot` | Paths starting with `.github/` |
79-
| `vscode` | Deprecated alias for `copilot` |
80-
| `claude` | Paths starting with `.claude/` |
81-
| `cursor` | Paths starting with `.cursor/` |
82-
| `opencode` | Paths starting with `.opencode/` |
83-
| `all` | `.github/`, `.claude/`, `.cursor/`, and `.opencode/` |
84-
85-
When no target is specified, APM auto-detects from the `target` field in `apm.yml`, falling back to `all`.
86-
87-
### Cross-target path mapping
66+
### Plugin layout normalization
8867

89-
Skills and agents are semantically identical across targets -- `.github/skills/X` and `.claude/skills/X` contain the same content. When the lockfile records files under a different target prefix than the one you are packing for, APM automatically remaps `skills/` and `agents/` paths:
68+
`apm pack` (default `--format plugin`) emits an Anthropic plugin directory regardless of which targets installed the source files. Skills and agents are semantically identical across targets, so APM normalizes paths into the plugin convention:
9069

9170
```
92-
apm pack --target claude
93-
# .github/skills/my-plugin/SKILL.md -> .claude/skills/my-plugin/SKILL.md
94-
# .github/agents/helper.md -> .claude/agents/helper.md
71+
.github/skills/my-plugin/SKILL.md -> skills/my-plugin/SKILL.md
72+
.claude/agents/helper.md -> agents/helper.md
9573
```
9674

97-
Only `skills/` and `agents/` are remapped. Commands, instructions, and hooks are target-specific and are never mapped.
98-
99-
The enriched lockfile inside the bundle uses the remapped paths, so the bundle is self-consistent. When mapping occurs, the `pack:` section includes a `mapped_from` field listing the original prefixes.
75+
Commands, instructions, and hooks are also rehomed under the plugin's top-level convention dirs. The bundle is self-consistent and target-agnostic; the consumer's project drives where files land at install time.
10076

10177
### Targeting mental model
10278

103-
**Choose your target when you pack. Unpack delivers exactly what was packed.**
79+
**Bundles are target-agnostic. The consumer's project decides where the files land.**
80+
81+
A bundle ships in Anthropic plugin layout (`agents/`, `skills/`, `commands/`, `instructions/`, `hooks/`) as a transport convention -- not a target binding. When a consumer runs `apm install <bundle>`, APM resolves the consumer's target from their project context (same precedence as registry installs: `--target` flag, then `apm.yml`, then directory detection) and routes the bundle's primitives through the integrators for that target.
10482

105-
A bundle is a deployable snapshot, not a retargetable source artifact. Target selection happens at pack time because that is when the full context is available -- which file types are remappable (skills, agents) and which are target-specific (commands, instructions, hooks).
83+
Concretely: the same `team-skills.tgz` installed into a Copilot project lands under `.github/`; installed into a Claude project, lands under `.claude/`; installed into an OpenCode project, lands under `.opencode/` with instructions staged for `apm compile`.
10684

107-
`apm unpack` does not remap paths. If the bundle was packed for Claude, the files land under `.claude/`. If you need a different target, re-pack from source with the desired `--target` flag, or use `--target all` to include all platforms.
85+
`--target` on `apm pack` is **deprecated**. The field is informational and never overrides consumer-side target resolution; an advisory warning may still print at install time if the bundle's recorded `pack.target` differs from the resolved install target.
10886

109-
When unpacking, APM reads the bundle's `pack:` metadata and shows the target it was packed for. If the bundle target does not match the project's detected target, a warning is displayed:
87+
Compile-only targets (OpenCode, Codex, Gemini) receive instructions under `apm_modules/<slug>/.apm/instructions/` so [`apm compile`](../../guides/compilation/) merges them into `AGENTS.md` / `GEMINI.md` on the next compile.
11088

11189
```
112-
$ apm unpack team-skills.tar.gz
113-
[*] Unpacking team-skills.tar.gz -> .
114-
[i] Bundle target: claude (1 dep(s), 3 file(s))
115-
[!] Bundle target 'claude' differs from project target 'copilot'
116-
[+] Unpacked 3 file(s) (verified)
90+
$ apm install team-skills.tgz
91+
[>] Installing local bundle from team-skills.tgz
92+
[*] Installed 3 file(s) from local bundle
93+
[!] Bundle staged 1 instruction(s) for compile (target: opencode). Run 'apm compile' to merge them into AGENTS.md / GEMINI.md / equivalent.
11794
```
11895

119-
This is informational -- the files still extract. The warning helps users understand why their tool may not see the unpacked files and suggests the correct workflow.
120-
12196
## Plugin format vs APM format
12297

12398
`apm pack` produces one of two output shapes. The default is the plugin format.
@@ -126,7 +101,7 @@ This is informational -- the files still extract. The warning helps users unders
126101
|---|---|---|
127102
| Output layout | Claude Code plugin directory with `plugin.json` at the root and convention dirs (`agents/`, `skills/`, `commands/`, `instructions/`, `hooks/`) | Mirrors `apm install` deploy paths (`.github/`, `.claude/`, `.cursor/`, `.opencode/`) plus an enriched `apm.lock.yaml` |
128103
| `plugin.json` | Synthesized (or updated from existing) and validates against the [official Claude Code plugin manifest schema](https://json.schemastore.org/claude-code-plugin.json) | Not emitted |
129-
| `apm.lock.yaml` inside output | Not emitted (no APM-specific files) | Enriched copy with a `pack:` metadata section |
104+
| `apm.lock.yaml` inside output | Enriched copy with a `pack:` metadata section (when the project has a lockfile) | Enriched copy with a `pack:` metadata section |
130105
| Drop-in for | Any Claude Code plugin consumer (Copilot CLI, Claude Code, Cursor, ...) | `microsoft/apm-action`'s restore mode and bundle-aware tooling |
131106
| `devDependencies` | Excluded | Included (full install layout) |
132107

@@ -279,15 +254,17 @@ build/my-project-1.0.0/
279254

280255
The bundle is self-describing: its `apm.lock.yaml` lists every file it contains and the dependency graph that produced them.
281256

282-
## Lockfile enrichment (APM format only)
257+
## Lockfile enrichment
283258

284-
When `--format apm` is used, the bundle includes a copy of `apm.lock.yaml` enriched with a `pack:` section. The project's own `apm.lock.yaml` is never modified.
259+
Both formats embed an enriched `apm.lock.yaml` in the bundle when the project has a lockfile. The project's own `apm.lock.yaml` is never modified; the embedded copy carries an additional `pack:` section so consumers verify integrity at install time without re-running the upstream pack.
285260

286261
```yaml
287262
pack:
288263
format: apm
289-
target: copilot
290264
packed_at: '2025-07-14T09:30:00+00:00'
265+
bundle_files:
266+
.github/prompts/design-review.prompt.md: a1b2c3...
267+
.github/agents/architect.md: d4e5f6...
291268
lockfile_version: '1'
292269
generated_at: '2025-07-14T09:28:00+00:00'
293270
apm_version: '0.5.0'
@@ -304,10 +281,14 @@ dependencies:
304281
- .github/agents/architect.md
305282
```
306283
307-
The `pack:` section records the bundle `format`, the effective `target` filter, and a `packed_at` UTC timestamp. Plugin-format output has no `apm.lock.yaml` -- consumers verify by re-running the upstream pack instead.
284+
The `pack:` section records the bundle `format`, the per-file `bundle_files` SHA-256 manifest, and a `packed_at` UTC timestamp.
308285

309286
## `apm unpack`
310287

288+
:::note
289+
For APM consumers, prefer `apm install <bundle>` over `apm unpack`. `apm install` deploys both formats target-agnostically, persists provenance to the project lockfile (`local_deployed_files`), and works with directory or `.tar.gz` inputs. `apm unpack` is retained for the legacy APM-format restore-without-APM workflow consumed by `microsoft/apm-action@v1`.
290+
:::
291+
311292
Extracts an APM bundle (produced with `--format apm`) into a project directory. Accepts both `.tar.gz` archives and unpacked bundle directories. Plugin-format output is consumed directly by Claude Code and other plugin hosts and does not need `apm unpack`.
312293

313294
```bash
@@ -462,4 +443,4 @@ During unpack, verification found files listed in the bundle's lockfile that are
462443
If `apm pack` produces zero files, check:
463444

464445
1. Your dependencies have `deployed_files` entries in `apm.lock.yaml`. This can happen if `apm install` completed but no integration files were deployed (e.g., the package has no prompts or agents for the active target).
465-
2. The `--target` filter matches where files were deployed. For example, if files are under `.github/` but you pack with `--target claude`, APM will remap `skills/` and `agents/` automatically. If no remappable files exist, the bundle will be empty. Try `--target all` or check `apm.lock.yaml` to see which prefixes your files use.
446+
2. The bundle is built from the `deployed_files` in `apm.lock.yaml` directly. Cross-target remapping for the convention dirs (`skills/`, `agents/`, `commands/`, `instructions/`, `hooks/`) runs automatically. If `apm.lock.yaml` shows zero deployed files, run `apm install` first; if files exist there but the bundle is empty, file an issue.

docs/src/content/docs/guides/plugins.md

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,7 @@ APM treats plugins and packages as the same artifact. Every APM package is plugi
88

99
## Plugin authoring
1010

11-
The only authoring decision is whether you also keep an `apm.yml`:
12-
13-
- **With `apm.yml` (recommended).** You get dependency management, lockfile pinning, [security scanning](../../enterprise/security/), [`devDependencies`](../../reference/manifest-schema/#5-devdependencies), and multi-runtime deploy during development. `apm pack` still emits a plugin-compatible bundle for non-APM consumers. This is what `apm init --plugin` produces.
14-
- **From an existing `plugin.json`.** APM consumes it natively - `apm install owner/repo` works against any plugin repo without migration. Metadata is synthesized from `plugin.json`. Add an `apm.yml` later if you want APM tooling during development.
15-
16-
For why `apm.yml` adds value on top of `plugin.json`, see [Anatomy -- Why not just ship a `plugin.json`?](../../introduction/anatomy-of-an-apm-package/#why-not-just-ship-a-pluginjson).
17-
18-
### Authoring workflow
19-
20-
```bash
21-
apm init my-plugin --plugin # apm.yml + plugin.json
22-
apm install --dev owner/helpers # dev-only dependency (excluded from pack)
23-
apm install owner/core-rules # production dependency
24-
apm pack # plugin-compatible bundle by default
25-
```
26-
27-
The packed directory contains no APM-specific files. See [Pack & Distribute -- Plugin format](../../guides/pack-distribute/#plugin-format-vs-apm-format) for the output mapping and [Without APM: what you give up](../../guides/pack-distribute/#without-apm-what-you-give-up) for the consumer-side trade-off.
11+
For the authoring flow (`apm init --plugin`, dev/prod dependency split, `apm pack` output mapping, hybrid mode with `apm.yml` + `plugin.json`), see [Pack & Distribute](../../guides/pack-distribute/) and the [first package walkthrough](../../getting-started/first-package/#6-ship-as-a-plugin-optional). For why APM still adds value when you already have a `plugin.json`, see [Anatomy -- Why not just ship a `plugin.json`?](../../introduction/anatomy-of-an-apm-package/#why-not-just-ship-a-pluginjson).
2812

2913
## Overview
3014

@@ -353,7 +337,7 @@ This:
353337

354338
## Exporting APM packages as plugins
355339

356-
Use the [authoring workflow](#authoring-workflow) to develop plugins with APM's full tooling and export them as standalone plugin directories. See [Pack & Distribute -- Plugin format](../../guides/pack-distribute/#plugin-format-vs-apm-format) for the output mapping and structure.
340+
Use the [authoring workflow](#plugin-authoring) to develop plugins with APM's full tooling and export them as standalone plugin directories. See [Pack & Distribute -- Plugin format](../../guides/pack-distribute/#plugin-format-vs-apm-format) for the output mapping and structure.
357341

358342
## Finding Plugins
359343

0 commit comments

Comments
 (0)