Skip to content

feat(agent-plugins): Agent Plugins 1.0.0 support — client pipeline, CAO-as-plugin packages, MCP delivery (#573) - #584

Draft
plauzy wants to merge 1 commit into
awslabs:mainfrom
plauzy:feat/agent-plugins-573-upstream
Draft

feat(agent-plugins): Agent Plugins 1.0.0 support — client pipeline, CAO-as-plugin packages, MCP delivery (#573)#584
plauzy wants to merge 1 commit into
awslabs:mainfrom
plauzy:feat/agent-plugins-573-upstream

Conversation

@plauzy

@plauzy plauzy commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Agent Plugins 1.0.0 — client pipeline, CAO-as-plugin packages, MCP delivery

CAO installing its own cao agent plugin through its own plugin pipeline, asserting every step

Implements Agent Plugins 1.0.0 support — the client install pipeline, two published CAO packages, and MCP-server delivery into agent profiles — as proposed in #573. Agent Plugins is the open, vendor-neutral packaging standard for Agent Skills + MCP servers, governed by a TSC drawing maintainers from AWS, Cursor, Microsoft, OpenAI and Vercel. Publishing to it means every compatible client becomes a distribution channel for CAO instead of another integration guide to maintain.

Every operator-facing surface is gated closed pending the naming decision (M1), and the gate now refuses execution rather than only hiding navigation — the correction review 2 asked for. One shared predicate reads CAO_AGENT_PLUGINS_ENABLED (default off), so the cao plugin group errors out and all four /plugins* routes return 404 without it; the four TUI rows are Policy::Hidden and filtered out of the command palette, and the Web UI panel is behind PLUGINS_TAB_ENABLED = false. Each gate has a test asserting the closed state, and the default-off tests deliberately do not use the fixture that opens it, so with no decision taken the CLI, API, TUI and Web UI are all inert for end users. The existing Python event-plugin system (docs/plugins.md, decision D7) is untouched — its diff is empty.

Single signed commit on top of main, with both review rounds folded in — haofeif's seven P2 findings and fanhongy's three P1 / three P2 findings, each with a test that fails without its fix.


What ships here

Concern This PR Follow-up
Client loader — manifest-first, closed-schema validation against vendored 1.0.0 schemas, fixed-location discovery, ./-rooted containment, report-and-continue failure boundaries
Skill projection — plugin skills projected into SKILLS_DIR as symlinks, reaching every provider incl. Kiro CLI + OpenCode
MCP mapping + delivery${PLUGIN_ROOT}/${PLUGIN_DATA} expansion, then merged into the agent profile and materialized into each provider's native config
CLIcao plugin add | list | validate | remove (behind the M1 gate)
Published packagesagent-plugin/cao (4 skills + cao-ops) and agent-plugin/cao-contributor (skills-only, a live §11.2 conformance instance)
Claude Code compatibility overlay — generated, drift-guarded, invisible to conformant clients
CI schema validationmake check-agent-plugins-schemas + make check-agent-plugin on every PR
Conformance corpus — one case per spec failure-isolation row, asserted exact-match
Web UI panel + TUI rows (built, tested, gated closed) M1 opens them
Dog-food example + shift-left recording (CI-gated GIF)
Exact removal cleanup on OpenCode (prune vs. disable) needs provenance — see below

Acceptance criteria cross-reference (#573)

AC Requirement Status Evidence
AC1 plugin.json + mcp.json validate against the canonical 1.0.0 schemas in CI on every PR make check-agent-plugins-schemas + make check-agent-plugin in CI. Schemas are vendored with recorded sha256s and validation is offline by construction — the registry refuses every retrieval, asserted by blocking socket creation process-wide and then validating a real plugin
AC2 cao plugin installs and works in ≥2 compatible clients: cao-ops callable, cao-session-management discovered Kiro CLI (native skill:// + mcpServers in the emitted agent JSON) and Claude Code 2.1.226 (a client outside the spec's listed set). In Claude Code: strict claude plugin validate passes, a marketplace install discovers all four skills and the cao-ops server, and its tools were exercised over MCP stdio against the published cli-agent-orchestrator==2.4.1 pin — a structured error naming operation and cause with no cao-server running, real data (list_sessions → {"success":true,...}) with one up
AC3 Contributor plugin validates and installs the same way agent-plugin/cao-contributor ships no mcp.json, exercising the skills-only conformance floor (§11.2)
AC4 cao plugin add installs the canonical example: skill delivered; invalid sibling skipped with a report; missing skills/ tolerated; fatal plugin.json violation rejects before any component loads test_conformance_corpus.py — 22 cases, exact-match, plus a guard asserting every corpus path is something git actually stores (an empty-by-design fixture directory once vanished between machines; that class of bug is now impossible)
AC5 Installed plugins listed/removed via CLI and Web UI; install from a GitHub URL works 🔒 All built and tested, gated closed by M1, each gate pinned by a test asserting the closed state. Opening them is a decision, not a code change
AC6 Naming decision (event plugins vs Agent Plugins) recorded and applied to docs before any public surface ships Vocabulary rule recorded and enforced by a guard. The backlog is cleared: the remaining two entries (cursor-cli.md, opencode-cli.md) are permanent, justified exemptions where the bare noun names a third party's own plugin concept that our qualifiers would misdescribe
AC7 Default posture unchanged: localhost-only cao-server, no credentials in package data, ./-rooted containment on install Containment property-tested; GET /plugins moved onto the read floor (it discloses local paths plus live terminal IDs, session and skill names), and test_scope_coverage.py pins today's ungated GETs as data so a new ungated GET fails the build

Demo recordings (shift-left — CI-gated proof-of-work)

The GIF above is generated by the build, not hand-recorded. Agent Plugins dog-food (shift-left recording) runs the asserting example — CAO installing its own cao package through its own plugin pipeline — and exports the GIF only when the run exits 0 and prints its PASS marker. A regressed pipeline cannot produce a green recording.

The gate was proven rather than asserted: neutralizing the OpenCode disable call made the recorder exit 1 with the GIF's sha256 and mtime unchanged — never produced — after which the source was restored byte-identical and the run went green again.

Step What it asserts
cao plugin validate ./agent-plugin/cao manifest loadable, the four shipped skills named, mcp_present with cao-ops
cao plugin add ./agent-plugin/cao each skill projected as a symlink whose target resolves into the plugin store — asserted on the link target, not on ls output
cao install … --provider kiro_cli agent JSON carries the skill:// globs and cao-ops with PLUGIN_ROOT/PLUGIN_DATA expanded to real paths, no internal marker leaked, @cao-ops granted
cao install … --provider opencode_cli opencode.json gets a real boolean "enabled": true; installing over a user's own same-named entry preserves it and emits a finding
cao plugin remove cao absent on Kiro/Copilot (wholesale rewrite); disabled on OpenCode (in-place edit). A live-gated step additionally observes no subprocess spawning, with a positive control that does

Runs offline under a scratch HOME/CAO_HOME_DIR/CAO_AGENTS_DIR — no provider binary, network or secrets, no real config read or written, and no real home path rendered (CONTRIBUTING.md § Recording test fixtures safely).


Try it in a client

Full per-client walkthrough: docs/agent-plugins.md § Trying it in a client. From a clone:

cao plugin validate ./agent-plugin/cao     # loadable? skills named? mcp present?
cao plugin add ./agent-plugin/cao

Kiro CLI — native, both routes. cao install <profile> --provider kiro_cli writes skill:// globs and the cao-ops server directly into the agent JSON. Because Kiro powers install Agent Plugins natively, the same directory is also installable as a power with no CAO-specific packaging.

Claude Codeclaude plugin validate ./agent-plugin/cao passes, then install through its marketplace flow. The useful check is the two-state one: with no cao-server running a cao-ops call returns a structured error naming the operation and cause; start one and the same call returns real data. That proves the tool is wired to a local CAO rather than merely registered.

Antigravity CLI / Gemini-family clients — Antigravity is a CAO provider and receives skills by runtime prompt injection plus the load_skill tool, so cao install <profile> --provider antigravity_cli is all that is needed; no client-side plugin install. For any other client that accepts an MCP server configuration, agent-plugin/cao/mcp.json is the portable declaration to copy — the same mcpServers shape the standard defines, pinned to a published version, needing no CAO-specific translation.

Verification status, stated plainly: the Kiro and Claude Code paths were exercised end to end against live clients. The Gemini-family note describes the portable declaration those clients consume; it is not a claim that each has been tested.


The Claude Code finding went upstream, not into a fork

Verifying AC2 against a real foreign client turned up a portability gap worth more than a local workaround: Claude Code 2.1.226 discovers the standard skills/ layout unchanged, but reads identity only from .claude-plugin/plugin.json and MCP servers only from a root .mcp.json — its strict validator rejects the root-manifest layout that the marketplace path tolerates.

CAO's side is a generated, drift-guarded overlay that conformant clients cannot see (§6.1's fixed component locations exclude dot-prefixed entries). The spec-pure alternative — a com.anthropic.claude-code/ extension directory per §8.2 — was rejected because Claude Code does not read extension directories, which would make the overlay decorative.

Both findings are being written up for the ecosystem rather than kept as CAO trivia — non-normative, implementer-facing notes for agentplugins/agent-plugins-spec (client manifest-location convergence, and glob-vs-symlink semantics for delegated discovery). Nothing in this PR depends on them.

The second finding is the kind of bug only a real client surfaces: a client that delegates skill discovery to another program's glob inherits that program's **-over-symlink behaviour. glob.glob(recursive=True) descends into symlinked directories; pathlib.Path.glob does not. Projected plugin skills are symlinks, so a pathlib-shaped reading inside a client would hide every plugin skill — invisibly, and untestable from CAO's side. CAO emits a single-level glob as well, which is immune, and the deviation is recorded with a tripwire test that fails if pathlib's behaviour ever changes.


How this was built

Worth stating because it is the reason the evidence above exists, and it maps onto the frontier-team practices rather than aspiring to them.

Intent was explicit before code. docs/issues/573-agent-plugins/ carries requirements with numbered acceptance criteria, a design with the rejected alternatives written down, and a sequenced task breakdown — following this repo's existing docs/issues/ convention. Tests cite the criteria they satisfy (**Validates: Requirement 13.2, 18.9**), so traceability is checkable rather than claimed. One requirement was amended mid-flight when the design turned out to presuppose a delivery obligation it never stated; the amendment note says so instead of quietly renumbering.

Correctness was derived from the requirements. Property-based (Hypothesis) suites cover validation totality, containment, install/remove algebra, delivery equivalence and the schema pin — asserting properties the requirements imply, not examples someone thought of. Where a duplicate of an existing property was skipped, the module docstring names the test that already covers it, so the decision is auditable.

Testing shifted left, and the guardrails have teeth. The recorder gates on a real run. make check-agent-plugin fails on package drift. The schema pin fails on tampering. Docs guards assert the warning is in the first screenful and the prerequisites are facts rather than guesses. A vocabulary guard keeps "event plugins" and "agent plugins" apart, with the two permanent exemptions listed as data. And new behaviour is mutation-verified: each fix was confirmed to fail when the thing it asserts is removed — stringify the disabled boolean, drop the collision guard, drop the reconcile, force the ownership predicate True, and each breaks its own test. One extra mutation was run purely to prove a test was not a tautology.

Agents were fed, not babysat. The work ran as parallel agents against written task files with an independent reviewer auditing the result, and the two hardest findings in this PR came from that adversarial loop rather than from CI: a review comment identified the OpenCode removal gap, and verifying that turned up a worse sibling — a silent overwrite of a user's hand-written config at install time.

The claims are bounded. Where something could not be verified it says so: AC5 is gated, the Gemini-family path is documented rather than tested, and the OpenCode behaviour is pinned to the version it was measured against.


The decisions worth reviewing

Skills are projected into SKILLS_DIR, not registered as a search directory. The highest-consequence choice here. Appending plugin roots to _skill_search_dirs() is a one-function change that cannot cover Kiro CLI or OpenCode: Kiro gets only skill:// globs rooted at SKILLS_DIR, OpenCode reads through a single symlink to it, so a skill stored anywhere else is invisible to both — including CAO's own default provider. Asserted in TestTheRejectedAlternativeWouldNotHaveWorked.

MCP servers are delivered by re-mapping, not by persisting the mapping. The expansions are absolute paths in args/env/cwd; a persisted record would serve paths that no longer exist once CAO_HOME_DIR moves, a --force reinstall would keep serving the old record, and an edited tree would disagree with its record silently. Re-mapping makes the delivered set a pure function of what is on disk now — the property projection.py gets by rebuilding rather than patching. Review 1 found the mapping was applied on the install path only, while five providers re-read the profile from disk at launch and discarded it; the merge now happens on the read seam that every launch passes through, so the property holds where it is actually consumed. Cost is one JSON read plus one offline validation per plugin.

Collisions resolve by a rule, never by history. installed_at is deliberately not the key — it would elect different winners for A-then-B and B-then-A from the same final installed set.

OpenCode's shared config needed two guards the other providers do not. Kiro's and Copilot's per-agent files are rewritten wholesale, so a withdrawn server simply is not written. OpenCode's opencode.json is edited in place and has no delete. Left alone that meant (a) a removed plugin's server survived with enabled: true pointing at a just-deleted PLUGIN_ROOT — a failing posix_spawn/ENOENT on every launch — and (b) an install silently overwrote a user's hand-written entry, because the "profile always wins" rule is scoped to the agent profile's mcpServers and never sees the shared file. Removal now disables in place; install refuses to overwrite an entry it cannot prove it placed. That disable rests on OpenCode not spawning enabled: false, verified rather than assumed — two strict enabled === false gates in the shipped 1.18.15 bundle both return before the only spawn path, confirmed empirically with a firing positive control. Because the gate is strict the helper writes a real JSON boolean; "false" or 0 would still spawn, and a unit assertion pins that. Ownership was originally a documented heuristic, claimed to fail only toward over-preservation. Review 1 disproved that: a force update that changed the command made CAO misclassify its own entry as user-owned, keeping the stale command and dropping the tool grant. Ownership is now decided by whether the entry's command resolves inside the CAO-managed plugin store, so CAO's own entries stay updatable while a genuinely user-authored entry is still never overwritten.


Testing

6987 passed, 29 skipped, 1 xfailed     full Python suite
 634 passed                            test/agent_plugins/ + docs guards
  90 passed                            test/plugins/ — event plugins (D7) untouched
  22 passed                            conformance corpus, exact-match
 106 passed                            web (vitest)
 143 passed                            Rust TUI

Remaining Python failures are pre-existing (test/services/agui, the AG-UI tests in test/api, OpenTelemetry) — none in any area this change touches, established by comparing failing-test-name sets against main in both directions rather than comparing counts. black/isort clean on the CI scope; mypy src/ matches its pre-existing baseline with no new errors in touched files; validate_markdown_links.py exits 0; cargo fmt / clippy -D warnings clean; make check-agent-plugin and make check-agent-plugins-schemas green.


Still open, by design

  • M1–M4 are maintainer decisions (CLI verb/naming, package names, the Web UI surface). Every surface they gate is built, tested and closed.
  • Antigravity CLI compatibility overlay — held back for a follow-up PR so this one stays scoped to the two review rounds. agy plugin install reads identity and skills from the untouched portable layout but takes plugin MCP servers only from a root mcp_config.json; the bridge is one generated byte-identical file, measured against 1.1.11.
  • Two permanent vocabulary exemptions (cursor-cli.md, opencode-cli.md), justified in the guard's own data rather than silently skipped.
  • Increment boundary — the task breakdown sequences delivery behind the loader landing; this ships both, because Increment 1 alone cannot close [Feat] Agent Plugins 1.0 support — package the CAO ecosystem as portable plugins + a plugin install/marketplace surface #573 (AC2 needs an mcp.json Increment 1 does not ship). Happy to split into two commits for review — commit re-ordering, not a rewrite.

I confirm this contribution is made under the terms of the project's Apache-2.0 license.

@codecov-commenter

codecov-commenter commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@135e7ff). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #584   +/-   ##
=======================================
  Coverage        ?   91.76%           
=======================================
  Files           ?      194           
  Lines           ?    25555           
  Branches        ?        0           
=======================================
  Hits            ?    23450           
  Misses          ?     2105           
  Partials        ?        0           
Flag Coverage Δ
unittests 91.76% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@plauzy plauzy self-assigned this Aug 10, 2026

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the reproducible issues below.

Non-blocking documentation note: docs/issues/ is a recent historical-design-record convention, introduced by #345 and explicitly reused by #568; it is not runtime or end-user documentation. Retaining durable decisions and the OpenCode verification evidence here is useful, but this PR adds 2,288 lines under docs/issues/573-agent-plugins/, including an unchecked 374-line implementation task plan and duplicated requirements. I recommend keeping the final design/provenance and dropping or relocating the planning artifacts before merge.

# utils/opencode_config.translate_mcp_server_config already derive every
# provider's native MCP form, so this single merge reaches all of them
# with no per-provider code.
profile.mcpServers, plugin_mcp = merge_plugin_mcp_servers(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Deliver plugin MCP servers on runtime-loaded provider paths

This merge only mutates the local parsed profile. No MCP-bearing artifact is written for Claude Code, Codex, Kimi, Antigravity, or Cursor, and _write_context_file persists the original raw profile; those providers later call load_agent_profile() again at launch, so these merged entries are gone. Copilot likewise builds its runtime MCP config with only cao-mcp-server. I reproduced an installed stdio plugin where install_agent("worker", "claude_code") succeeds but the eventual Claude command contains neither --mcp-config nor the plugin server. Merge/remap on the actual launch profile (and extend Copilot's runtime config), or persist configuration that those providers consume; the all-provider tests need to inspect real launch commands rather than treating collect_plugin_mcp_servers() as delivery.


for skill_name in sorted(set(previous) - set(current)):
path = target_dir / skill_name
if _remove_quiet(path):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve a user skill that replaced a copied projection

previous records only the old projection claim, not the current path's ownership. In supported copy mode, cao skills add <folder> --force successfully replaces the copied plugin skill with a user-owned directory, but a later cao plugin remove reaches this loop and recursively deletes that new directory because its name is still in previous. I reproduced the sequence and the user skill is gone after removal; any intervening projection rebuild also overwrites it with the plugin copy. Verify a marker/hash before deleting copied projections or have the skills command transfer ownership so the documented "user-added skill always wins" rule remains true after installation order changes.

return True
if existing.get("enabled") is False and entry_within_roots(existing, plugin_store_roots):
return True
return False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Recognize changed CAO entries during OpenCode updates

A force update that changes a plugin server's command, args, or environment makes the candidate differ from the enabled entry CAO wrote previously. This function then returns false, so _materialize_opencode_mcp misclassifies CAO's own v1 entry as user-owned, leaves the stale v1 command in opencode.json, and removes the agent's tool grant because the v2 entry was dropped. I reproduced exactly that result; the same path breaks a lexicographic winner transition between plugins. Persist exact ownership (record or marker) so enabled CAO entries can be updated without weakening the guard for genuinely user-authored entries.

_ALL_TRANSPORTS = frozenset({"stdio", "streamable-http", "sse"})
PROVIDER_TRANSPORTS: Dict[str, frozenset] = {
"opencode_cli": _STDIO_ONLY,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject HTTP transports for providers that cannot serialize them

The default-all assumption is not true for every provider. Codex's serializer emits only command, args, and env overrides and never emits url; Antigravity similarly always writes a local command/args entry. Consequently map_mcp_config(..., provider="codex") accepts a streamable-http server, but the generated Codex command contains the server name with no URL or command, so it cannot start. Add the actual provider capability entries (at least Codex and Antigravity as stdio-only) or implement their HTTP translations before reporting these transports as delivered.

Comment thread src/cli_agent_orchestrator/api/main.py Outdated
from cli_agent_orchestrator.agent_plugins.installer import PluginInstallError, install

try:
outcome = install(_plugin_source(body), force=body.force)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Move plugin resolution off the FastAPI event loop

This is an async def handler but it directly runs the synchronous resolve/validate/publish/refresh pipeline. A git source can block in subprocess.run for up to 300 seconds, and a large local source blocks in copytree; during that time the server loop cannot service health/session requests or run status and inbox tasks. The validate call at line 2222 and uninstall path have the same problem. Run these operations in a worker thread (or make the handlers synchronous so FastAPI does so) rather than freezing the orchestrator during a normal plugin operation.

if backup is not None:
_rmtree_quiet(backup)

self.write_record(record)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Retain the old root until the install record commits

On a force update the finally above deletes the backup before this record write. If the state write fails (for example, the state volume is full or loses permission), publish() raises but the new package bytes have already replaced the old root, the old backup is gone, and the old record remains. I reproduced a v1 record paired with v2 bytes after an injected write_record failure, contradicting the failed-install isolation guarantee. Keep the backup through the metadata commit and restore the prior root/record on failure (and remove a new root when an initial record write fails).


record_path = self._record_path(validated)
if record_path.exists():
record_path.unlink()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the record when deleting the plugin root fails

_rmtree_quiet(root) swallows OSError, but this code still marks the plugin removed and unlinks its record. With a simulated busy/permission failure, unpublish() returns true while the full plugin root remains and get() returns none; cao plugin remove therefore reports success and leaves an untracked installation that blocks a later non-force add. This is especially plausible on Windows while an MCP executable is still open. Make root deletion report failure and only remove the record after the root is actually gone; apply the same truthful result handling to purge_data.

@plauzy

plauzy commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Hmmm CI tests are taking extremely long to run ... i dont think its my change as I see the same happening on other PRs, but making a note to create an issue for it later.

@fanhongy fanhongy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Request changes. The PR exposes surfaces that its own unresolved M1 gate says must not ship, and it has two reproducible state-integrity paths that can delete a user skill or corrupt plugin records. Three additional failure paths leave operations partially committed or report failure while continuing in the background.

Findings

P1: The unresolved M1 gate does not prevent the public surfaces from executing

src/cli_agent_orchestrator/cli/main.py:55 registers cao plugin unconditionally. hidden=True only removes it from help; cao plugin list --json succeeds at this head. The API is likewise live and discoverable through the routes registered at src/cli_agent_orchestrator/api/main.py:2180. This contradicts Requirement 16.5 and the AC6 traceability row, which explicitly gate the CLI/API/web surfaces until maintainers resolve M1. Merging this ships the unresolved command name and mutating API despite the claimed closed gate. Either resolve M1 and update the specification/docs in this PR, or conditionally omit command and route registration until the decision is made; hiding navigation is not an execution gate.

P1: A failed ownership transfer allows plugin removal to delete the user's replacement skill

src/cli_agent_orchestrator/agent_plugins/projection.py:123 catches every record-write failure and returns None, which is indistinguishable from "no plugin owns this skill." The force-install caller then deletes the projected entry and installs the user's copy at src/cli_agent_orchestrator/cli/commands/skills.py:45. Because the plugin record still claims the name, a later plugin uninstall sweeps that path and recursively deletes the user's skill. This reproduces when .state is read-only while SKILLS_DIR remains writable. Propagate a distinct failure and abort before replacing the projection, or make ownership transfer and replacement one atomic operation.

P1: Projection write-back can overwrite newer store state

src/cli_agent_orchestrator/agent_plugins/projection.py:628 rewrites a complete PluginRecord captured earlier at line 178, outside the lock used by publish() and unpublish(). Concurrent API requests can therefore commit an update/removal between the snapshot and this write. A stale rebuild then reverts force-update metadata or recreates a removed record; an isolated reproduction left get("demo") returning version 1.0 while the plugin root no longer existed. Serialize projection record updates with store mutations, or use a compare-and-swap that updates only projected_skill_names after verifying the current record identity and root.

P2: Plugin-data creation failure leaves an install committed after reporting failure

src/cli_agent_orchestrator/agent_plugins/installer.py:147 creates PLUGIN_DATA after store.publish() has committed the root and record. If the data directory is unwritable, install() raises, but the plugin remains installed without projection; a retry is then rejected as already installed. The isolated reproduction produced record_exists=True, root_exists=True, and skill_projected=False. Preflight/create the data directory before publication with cleanup on failure, or roll back the publication when this mandatory step fails.

P2: Purge failure removes the plugin record before returning an error

src/cli_agent_orchestrator/agent_plugins/store.py:368 deletes the install record before attempting data deletion at line 373. If the data tree is busy or undeletable, unpublish(..., purge_data=True) raises after the root and record are already gone, while its error incorrectly says the installation remains tracked. The command cannot be retried because the plugin now appears absent, and the requested data remains stranded. Make root, record, and data removal transactional, retaining or restoring tracked state when purge fails.

P2: The web client times out while the server can still complete the install

web/src/api.ts:390 aborts install and validation requests after 120 seconds, but the resolver permits a git subprocess to run for 300 seconds and asyncio.to_thread continues after client disconnect. A clone taking 121-300 seconds therefore reports failure in the panel while the backend can still publish the plugin, so retrying can produce an unexpected "already installed" result. Use a timeout longer than the server's maximum operation, or expose cancellable/job-based status so an aborted request cannot silently finish.

Validation

  • Verified checkout HEAD: 747509672171d863ea19434495b02b834469a063.
  • Read and hash-verified all acquisition artifacts; diff.patch contains 55,282 lines.
  • Focused tests under isolated CAO/HOME paths: 61 passed, 1 skipped.
  • Reproduced stale-record resurrection, user-skill deletion after failed claim release, partial install after PLUGIN_DATA failure, and partial purge after data deletion failure.
  • Confirmed cao plugin list --json executes and all four /plugins routes are registered.
  • git diff --check reports four trailing-whitespace additions; no P3 finding was raised for those.
  • The first non-isolated focused run saw one environment-dependent API assertion fail because it discovered live local CAO sessions; the isolated rerun passed.

@plauzy
plauzy force-pushed the feat/agent-plugins-573-upstream branch 2 times, most recently from 88a10b3 to 46f6e51 Compare August 11, 2026 15:45
…AO-as-plugin packages, MCP delivery (awslabs#573)

Adopts the [Agent Plugins 1.0.0](https://agent-plugins.org/specification) packaging
standard in both directions: CAO installs agent plugins, and CAO ships as one.

## Client pipeline

`cao plugin add|list|validate|remove` plus `GET/POST /plugins`,
`POST /plugins/validate` and `DELETE /plugins/{name}`, over a manifest-first
pipeline in `src/cli_agent_orchestrator/agent_plugins/`: `resolver` (local path or
git clone, containment-checked), `validation` (both closed schemas, vendored and
byte-pinned — no schema retrieval at load time), `store` (transactional publish
under a cross-process lock), `projection` (plugin skills into the existing skill
store, deterministic collision rule, user-installed skills always win),
`mcp_mapping` + `mcp_delivery` (per-provider MCP config with `${PLUGIN_ROOT}` /
`${PLUGIN_DATA}` expansion), and `provenance`. Failure isolation follows the
conformance floor: an invalid skill is skipped with a finding while its siblings
load, an invalid `mcp.json` disables MCP for that plugin only, and a missing
`skills/` directory is a valid absence.

## CAO as an agent plugin

`agent-plugin/cao` (operator: session-management skill + the `cao-ops` stdio MCP
server) and `agent-plugin/cao-contributor` (authoring skills), both generated from
the canonical `skills/` tree by `scripts/build_agent_plugin.py` and byte-diff
guarded by `make check-agent-plugin`, so the committed packages cannot drift from
their sources. Each carries a Claude Code compatibility overlay
(`.claude-plugin/plugin.json` and a `.mcp.json` byte-identical to `mcp.json`),
measured against Claude Code 2.1.226.

## Ship gate — Requirement 16.5

The whole management surface is default-off and **refuses to execute**: one shared
predicate, `agent_plugins.gate.agent_plugins_surface_enabled()`, reads
`CAO_AGENT_PLUGINS_ENABLED` (`1`/`true`/`yes`, matching the `CAO_AGUI_ENABLED` and
`CAO_EAGER_INBOX_DELIVERY` precedents), so the CLI group and the four HTTP routes
cannot disagree about whether the surface is live. Without it the routes 404 and
every CLI verb errors out naming the variable. The web tab is additionally off at
build time (`PLUGINS_TAB_ENABLED`) and the four TUI rows are `Policy::Hidden` and
filtered out of the command palette. Environment-only by design: this is a release
gate, not a user preference. Maintainer decisions M1–M4 stay open — nothing here
resolves the verb, the route paths, or the extension namespace.

## Review round 1 (haofeif) — seven P2 findings

* store not transactional: `publish` now holds the aside-backup through the record
  commit and rolls back on failure; `unpublish` reports deletion failure and unlinks
  the record only after the bytes are confirmed gone, as does `purge_data`;
* blocking the event loop: resolve/validate/publish/uninstall run via
  `asyncio.to_thread`;
* an untrue transport table: Codex and Antigravity audited stdio-only, and
  `DEFAULT_TRANSPORTS` made explicit for all ten providers;
* a user skill destroyed on rebuild: `cao skills add --force` transfers ownership;
* OpenCode ownership misclassified after a command change: ownership is decided by
  whether the command resolves inside the CAO-managed plugin store;
* MCP servers computed but not delivered at launch: one shared apply function on
  both the install and launch paths, since five providers re-read the profile from
  disk at launch;
* a publish race: `flock` on the state directory plus a swap-time `force` re-check.

Two existing tests that encoded the defective expectations were inverted with their
reasoning recorded.

## Review round 2 (fanhongy) — three P1, three P2

* **P1, the M1 gate did not prevent execution.** `hidden=True` suppresses help text
  while Click's dispatch is untouched, and the routes' only dependency was
  `require_any_scope(...)` — authorization, a no-op when auth is disabled, not a
  feature gate. Fixed by the execution gate described above, and the prose that
  documented the advertisement-only stance is rewritten in all five places it
  appeared.
* **P1, a failed ownership transfer deleted the user's skill.**
  `release_projection_claim` returned `None` both for "no plugin held this name" and
  for "the record write failed", so a force install proceeded to replace the
  projection while the record still claimed the name, and the next sweep removed the
  user's real directory by name alone. Two layers: `ProjectionClaimError` makes the
  release tri-state and `_install_skill_folder` aborts before unlinking anything; and
  `_sweep` phase one now requires `_is_managed_projection` — the realpath-containment
  test phase two already had — reporting a `projection.sweep_skipped_unmanaged`
  finding instead of deleting silently. Residual stated in the code: a copy-mode
  projection left by an earlier copy-mode rebuild is not swept by a later
  symlink-mode rebuild, which over-preserves rather than losing data.
* **P1, the projection write-back raced the store lock.** `rebuild_projection`
  snapshotted the installed set, did slow filesystem work, then rewrote a complete
  record from that stale snapshot with no lock, reverting a concurrent publish or
  resurrecting a removed record. `InstalledPluginStore.update_projected_names` is now
  a compare-and-set under the lock that re-reads fresh, skips a vanished record, and
  patches only `projected_skill_names`; `release_projected_name` is its sibling.
  Deliberately narrow — locking the whole rebuild would serialize installs behind
  slow filesystem work.
* **P2, `PLUGIN_DATA` created after the commit** left root + record installed with no
  data directory when the data volume failed, and the retry was refused as
  already-installed. Created before `store.publish` now, so a failure aborts before
  any commit.
* **P2, purge deleted the record before the data**, so a purge failure raised an error
  promising the install "remains tracked and can be retried" when the record was gone.
  The purge moved above the unlink; the retry re-runs because root deletion treats
  absence as success.
* **P2, the web client aborted at 120s** against a 300s server git budget, on an
  `asyncio.to_thread` call that cannot be cancelled. Both cloning operations now wait
  330s, with the comment deriving the number from `resolver.GIT_TIMEOUT_S` by name.

Also clears the four trailing-whitespace additions `git diff --check` reported (fixed
in the canonical `skills/` sources and both mirrors regenerated, not by hand-editing
generated trees), and teaches the dog-food recorder to open the ship gate it now
depends on.

## Verification

`test/agent_plugins/` carries 814 passing tests, including the conformance corpus, the
property suites, per-provider delivery equivalence against each provider's real launch
artifact, and fault injection at every commit point. Every review fix has a test that
fails without it, mutation-verified by reverting the fix and confirming the source
restores byte-identical. The full suite's failures are exactly the pre-existing AG-UI
(58) and OpenTelemetry (3) set with the same per-file grouping as `main`.
`make check-agent-plugin`, `make check-agent-plugins-schemas`, the docs vocabulary
guard, the link validator, `black`/`isort`, `npm run build` in `web/`, and the
end-to-end dog-food recorder all pass.

Closes awslabs#573

Two further gaps, found by an independent read-only audit of this commit rather
than by the review, are closed here too:

* the ship gate was applied as each handler's *first statement*, which is not
  first in the request: FastAPI validates the body and solves the scope
  dependency before the handler runs, so a malformed payload answered 422 and an
  unauthorized caller 401/403 — each disclosing that the gated route exists. The
  gate is now a route-level dependency, verified to answer 404 ahead of both, so a
  disabled surface is indistinguishable from an unregistered path;
* the F2 containment guard covered the sweep's delete-by-name path but not
  ``_place``, which removes whatever occupies the target before writing — the same
  ``shutil.rmtree`` on a real directory. Materialization now refuses a real
  directory at a name the previous projection did not own, reporting
  ``projection.target_not_ours``; a name the projection did own stays replaceable,
  which keeps plugin upgrades and copy-to-symlink migration working.

Co-authored-by: plauzy <4451274+plauzy@users.noreply.github.com>
Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com>
@plauzy
plauzy force-pushed the feat/agent-plugins-573-upstream branch from 46f6e51 to a3cc8a7 Compare August 11, 2026 16:26
@plauzy

plauzy commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Moving this change back to Draft while I run a few manual analyses and audits - all findings are addrssed to be clear, but want to ensure the quality bar is highest it can be.

Will re-open and reply in-line to all comments verifying once done and ready for new review.

@plauzy
plauzy marked this pull request as draft August 11, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Agent Plugins 1.0 support — package the CAO ecosystem as portable plugins + a plugin install/marketplace surface

4 participants