From f0170f27dbecd815e68b10002552be34591664d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Mon, 7 Sep 2026 18:42:37 +0200 Subject: [PATCH 1/4] fix: preserve plugin inventories and runtime boundaries Replay captured sdist files and replace wheel payloads by their installed paths. Align discovery with Python metadata precedence and recheck lazy document and MCP paths while preserving the public API. --- development_docs/architecture.md | 12 +- development_docs/artifacts.md | 10 +- development_docs/validation.md | 8 +- docs/guide/artifact-lifecycle.md | 2 +- docs/guide/inspect-installed.md | 22 +- docs/guide/validation.md | 4 + docs/reference/cli.md | 2 +- docs/reference/pyproject.md | 2 +- docs/reference/python-api.md | 19 +- src/agent_plugins/_build/plan.py | 74 ++++- src/agent_plugins/_build/sdist.py | 4 +- src/agent_plugins/_build/wheel.py | 324 +--------------------- src/agent_plugins/_build/wheel_archive.py | 292 +++++++++++++++++++ src/agent_plugins/_discovery.py | 10 +- src/agent_plugins/_files.py | 2 +- src/agent_plugins/_mcp.py | 198 +++++++++++++ src/agent_plugins/_plugin.py | 11 +- src/agent_plugins/_schema/json.py | 15 +- src/agent_plugins/_schema/lazy.py | 3 +- src/agent_plugins/_schema/manifest.py | 11 +- src/agent_plugins/_schema/mcp.py | 187 ++----------- src/agent_plugins/_schema/skill.py | 8 +- src/agent_plugins/_skill.py | 10 +- tests/test_build_backends.py | 63 +++-- tests/test_discovery.py | 183 ++++++------ tests/test_manifest.py | 26 +- tests/test_mcp.py | 1 - tests/test_mcp_resolution.py | 115 +++++++- tests/test_plan.py | 48 ++++ tests/test_plugin.py | 116 +++++++- tests/test_skill.py | 17 +- tests/test_wheel.py | 168 ++++++++--- tests/wheel_assertions.py | 32 +++ 33 files changed, 1304 insertions(+), 695 deletions(-) create mode 100644 src/agent_plugins/_build/wheel_archive.py create mode 100644 src/agent_plugins/_mcp.py create mode 100644 tests/wheel_assertions.py diff --git a/development_docs/architecture.md b/development_docs/architecture.md index 400679c..e8e1822 100644 --- a/development_docs/architecture.md +++ b/development_docs/architecture.md @@ -43,9 +43,10 @@ other source → supplied BuildPlan │ │ │ | Owner | Contract | | --- | --- | -| `_build/plan.py` | Resolve project configuration and produce ordered source-to-target mappings | +| `_build/plan.py` | Select authored files, replay staged payloads, and validate source-to-target mappings | | `_build/backend.py` | Delegate PEP 517 and PEP 660 hooks, then augment artifacts | -| `_build/wheel.py` | Validate and attach regular wheel payloads, return `WheelAttachment`, and attach editable markers internally | +| `_build/wheel.py` | Own attachment, temporary artifact lifetime, publication, and `WheelAttachment` results | +| `_build/wheel_archive.py` | Validate ZIP members, replace plugin payloads and markers, and rebuild `RECORD` | | `_build/sdist.py` | Stage the selected payload under `.agent-plugin/` | | `build/uv_build.py` | Public uv_build adapter module | | `build/hatchling.py` | Public Hatchling adapter module | @@ -55,7 +56,8 @@ other source → supplied BuildPlan │ │ │ | `_plugin.py` | Construct project-selected handles and compose named manifest, MCP, and skill access around one inventory | | `_skill.py` | Expose exact skill source, checked selected files, native joins, and tree rendering | | `_schema/manifest.py` | Dispatch and cache manifest validation | -| `_schema/mcp.py` | Dispatch and cache MCP validation, then resolve stdio declarations against runtime roots | +| `_schema/mcp.py` | Dispatch and cache MCP validation and expose the `MCPConfig` facade | +| `_mcp.py` | Resolve validated stdio declarations against current files, data directories, and environment values | | `_schema/models.py` | Hold immutable normalized document and stdio launch values | | `_schema/skill.py` | Split UTF-8 `SKILL.md` source at exact delimiters | | `_schema/v1/` | Validate Agent Plugins 1.0.0 documents | @@ -66,7 +68,9 @@ other source → supplied BuildPlan │ │ │ The public `agent_plugins` package re-exports build planning, wheel attachment, discovery, filesystem, schema-value, and diagnostic types. `agent_plugins.build` separately exports the low-level `BuildBackend`. -Private build code depends on the plan and marker codec. Runtime discovery depends on the marker codec and file inventory. Project inspection composes build planning with the same inventory model. Schema loaders do not depend on build or discovery code. +Artifact operations depend on plan validation and archive writers. Archive writers depend on plan values and the marker codec. Runtime discovery depends on the marker codec and file inventory. Project inspection composes build planning with the same inventory model. + +Versioned schema loaders produce normalized values. Stdio resolution consumes those values and the selected inventory, independently of JSON parsing. `MCPConfig` composes cached document access with uncached launch preparation. Schema loaders and launch preparation do not depend on build or discovery code. The CLI parses input, calls public-domain functions, and renders output. Core modules do not depend on terminal state. diff --git a/development_docs/artifacts.md b/development_docs/artifacts.md index 8f8a46a..12e7fdb 100644 --- a/development_docs/artifacts.md +++ b/development_docs/artifacts.md @@ -8,10 +8,12 @@ Configuration adapters produce a validated `BuildPlan`. `attach_wheel()` consume The configured `root` resolves from that directory. When `project/.agent-plugin/plugin.json` exists, the staged directory takes precedence. This is how a wheel rebuilt from a source distribution selects the payload captured in that source distribution. -The plan adds required `plugin.json`, an optional recursive `skills/` tree, optional root-level `mcp.json`, and every `include` match. Target keys deduplicate mappings and sort by raw POSIX path. +For an authored root, the plan adds required `plugin.json`, an optional recursive `skills/` tree, optional root-level `mcp.json`, and every `include` match. For a staged root, it inventories the complete captured payload. Authored include patterns have already selected that payload. Target keys deduplicate mappings and sort by raw POSIX path. Selection rejects directory symlinks, paths that resolve outside the plugin root, absolute include patterns, parent traversal, and backslashes. File symlinks remain acceptable when their targets resolve inside the root. +Supplied plans are checked before wheel rewriting. Targets must be unique portable file paths, must include `plugin.json`, and must have readable regular sources. NUL characters and file-directory target collisions are rejected. + ## Backend delegation `BuildBackend(module)` imports the delegate by module name for each hook call. The delegate contract contains six methods: @@ -37,7 +39,7 @@ The wheel must contain exactly one top-level `.dist-info/WHEEL` member plus sibl -.agent-plugin/ ``` -The rewrite removes any existing owned plugin directory, `agent_plugins.json`, `RECORD`, and `RECORD` signature files. It copies every other member with its bytes and relevant `ZipInfo` metadata, preserves the archive comment, writes the selected plugin payload in plan order, writes a compact marker, then creates a new `RECORD`. +`wheel.py` owns the operation and publication. `wheel_archive.py` inspects the ZIP layout, replaces the owned plugin directory, `agent_plugins.json`, `RECORD`, and `RECORD` signature files, and copies every other member with its bytes and relevant `ZipInfo` metadata. It preserves the archive comment, writes the selected plugin payload in plan order, writes a compact marker, then creates a new `RECORD`. Added members use the ZIP epoch timestamp, deflate compression, and source permission bits. `RECORD` contains one row per non-directory member. Its hashes use SHA-256 with URL-safe base64 and no padding. The `RECORD` row itself has empty hash and size fields. @@ -45,13 +47,15 @@ Added members use the ZIP epoch timestamp, deflate compression, and source permi The rewrite uses a temporary file in the destination directory and applies the source artifact mode before publication. In-place attachment replaces the source after the complete rewrite succeeds. `output_dir` preserves the source, keeps its filename, and uses a no-clobber publish operation. Planning, validation, source reads, ZIP writing, and publication failures leave the source bytes unchanged. Temporary artifacts are cleaned after success and failure. +Ownership uses installation paths as well as top-level archive paths. Payloads and markers under wheel `.data/purelib/` and `.data/platlib/` locations are replaced when they install into the owned plugin or marker path. Conflicting relocated distribution metadata is rejected before publication. + An existing marker or matching plugin payload sets `replaced_existing_plugin`. The rewrite removes that owned state before writing the current plan. Repeating the same attachment is byte-deterministic. ## Source distribution rewrite The source artifact must be a `.tar.gz` archive with one safe top-level directory. Absolute paths, parent traversal, or multiple roots are rejected. -The rewrite replaces any existing `/.agent-plugin/` subtree and adds the planned payload there. New members preserve source modes and modification times while normalizing user and group identifiers to zero. The gzip header timestamp is zero. Temporary replacement also preserves the source distribution's outer file mode. +The rewrite compares canonical member paths to replace the complete `/.agent-plugin/` subtree, then adds the planned payload there. New members preserve source modes and modification times while normalizing user and group identifiers to zero. The gzip header timestamp is zero. Temporary replacement also preserves the source distribution's outer file mode. The staged payload ensures that a later wheel rebuild carries the same selected plugin bytes. Complete wheel byte reproducibility remains owned by the delegated backend and source metadata. diff --git a/development_docs/validation.md b/development_docs/validation.md index 217c932..ef890b7 100644 --- a/development_docs/validation.md +++ b/development_docs/validation.md @@ -10,7 +10,7 @@ Project and installed discovery create a bounded filesystem inventory first. Doc `locate(distribution_name)` asks `importlib.metadata` for one Python distribution and reads its `agent_plugins.json` file. A relative marker root resolves through `Distribution.locate_file()`. An absolute root supports editable installs. -`installed()` scans every visible distribution, skips unmarked entries, and sorts the result by distribution name without regard to case. It is fail-fast. One invalid marked distribution aborts the complete scan. +`installed()` selects the first visible installation of each normalized distribution name, then skips unmarked entries and sorts the result by distribution name without regard to case. This preserves `importlib.metadata` precedence even when an unmarked installation shadows a marked one. It is fail-fast. An invalid selected marked distribution aborts the complete scan. The distribution name remains independent from the manifest plugin name. @@ -30,6 +30,8 @@ Relative names reject absolute paths, `.`, parent traversal, and backslashes. Ev `LazyResult` evaluates a loader once under a lock. It caches either the returned value or the raised exception, then releases its loader reference. +A `BaseException` interruption before a value or ordinary exception is cached releases the lock and retains the loader for a retry. + The two state boundaries are: 1. `Plugin` and `Skill` construction captures the selected file inventory. @@ -37,6 +39,8 @@ The two state boundaries are: A new handle refreshes document content. An editable reinstall is also required when the selected filenames change. +Inventory-bound manifest, MCP, and skill loaders recheck the document's containment immediately before the first read. A failed check becomes a cached `ValidationError`. Native paths remain ordinary filesystem paths, and validation is not an atomic filesystem sandbox. + ## Manifest validation `Manifest` dispatches by exact `$schema` identifier. The current loader accepts Agent Plugins 1.0.0. @@ -61,7 +65,7 @@ The versioned loader owns: - URL user-information, fragment, escaping, whitespace, and port checks. - HTTP header name, value, and case-insensitive uniqueness checks. -The loader preserves placeholder strings. `MCPConfig.resolve_stdio()` applies one-pass placeholder expansion, resolves selected plugin commands and working directories, and returns immutable subprocess inputs. Runtime resolution is uncached because it depends on the caller's data directory, base environment, and current filesystem. +The loader preserves placeholder strings. `MCPConfig.resolve_stdio()` delegates to `_mcp.py`, which applies one-pass placeholder expansion, resolves selected plugin commands and working directories, and returns immutable subprocess inputs. Runtime resolution is uncached because it depends on the caller's data directory, base environment, and current filesystem. Process creation, permissions, transport connection, authentication, logging, and the MCP handshake remain client-owned. diff --git a/docs/guide/artifact-lifecycle.md b/docs/guide/artifact-lifecycle.md index 1cff0b9..933bf47 100644 --- a/docs/guide/artifact-lifecycle.md +++ b/docs/guide/artifact-lifecycle.md @@ -53,7 +53,7 @@ The built distribution receives no runtime dependency on `agent-plugins` unless A source distribution, or sdist, carries the selected files under a reserved `.agent-plugin/` staging directory. -When a build frontend reconstructs a wheel from the sdist, `build_plan()` selects this staged copy. The rebuilt wheel therefore carries the same Agent Plugin payload as the source checkout used for the original sdist. +When a build frontend reconstructs a wheel from the sdist, `build_plan()` uses the complete staged file selection. The rebuilt wheel therefore carries the same Agent Plugin payload captured in the source distribution, including files selected by authored include patterns. ## Editable installs diff --git a/docs/guide/inspect-installed.md b/docs/guide/inspect-installed.md index 13ca3a6..3d0e64c 100644 --- a/docs/guide/inspect-installed.md +++ b/docs/guide/inspect-installed.md @@ -5,19 +5,23 @@ description: Locate Agent Plugins by Python distribution name and inspect their # Inspect installed Agent Plugins -`agent-plugins` discovers plugins through Python distribution metadata. Each participating distribution installs an `agent_plugins.json` marker that records the plugin root and selected filenames. +Use `locate()` to read the Agent Plugin shipped with an installed Python library. Install `agent-plugins` in the same environment as that library: + +```console +pip install agent-plugins +``` ## Locate one distribution ```python import agent_plugins as ap -plugin = ap.locate("my-project") +plugin = ap.locate("agent-plugins") print(plugin.path) print(plugin.tree()) ``` -Pass the Python distribution name used by `pip`. This value is independent from `plugin.manifest.name`. +This example opens the Agent Plugin bundled with `agent-plugins` itself. Pass your library's Python distribution name to inspect its plugin. The name used by `pip` is independent from `plugin.manifest.name`. `locate()` raises `AgentPluginError` when the distribution is absent, has no `agent_plugins.json` marker, carries outdated or invalid marker metadata, or references an unusable file inventory. @@ -30,7 +34,7 @@ for distribution, plugin in ap.installed().items(): print(distribution, plugin.path) ``` -`installed()` returns a dictionary sorted by distribution name without regard to case. Distributions without a marker are skipped. +`installed()` returns a dictionary sorted by distribution name without regard to case. Distributions without a marker are skipped. When the environment exposes multiple installations of the same normalized distribution name, the first one found by Python metadata discovery takes precedence. The scan agrees with `locate()` for that installation. Discovery is fail-fast. A marked distribution with invalid metadata or files raises `AgentPluginError` and stops the scan. @@ -53,7 +57,7 @@ An installed `Plugin` handle exposes exactly the paths recorded by the marker. [ ## Use the API from a code-mode agent -A code-mode agent that can execute Python can use the installed distribution as its plugin source. It can use `agent-plugins` to inspect the manifest and MCP configuration, select an Agent Skill, read its instructions, and open client extension files when the current task needs them. +An agent with Python execution can inspect the dependencies in its environment, read a relevant skill, then write Python for the task. This works for a short script, an interactive session, or a notebook. The host supplies the model and execution policy. ```python import agent_plugins as ap @@ -62,14 +66,18 @@ plugin = ap.locate("my-project") skill = plugin.skill("use-my-project") print(skill.tree(max_depth=2)) -instructions = skill.source +print(skill.source) reference = skill.file("references/api.md") -reference_text = reference.read_text(encoding="utf-8") +print(reference.read_text(encoding="utf-8")) ``` +This example assumes the plugin includes `references/api.md`. Use the names shown by `skill.tree()` to choose resources. + `skill.tree()` exposes the bounded file structure before the agent chooses what to read. `skill.source`, `skill.frontmatter`, and `skill.body` share one lazy read. `skill.file()` checks the selected inventory and containment before the agent reads a reference or runs a script through its normal code-execution tools. +Read instructions from a distribution trusted by the host before using its code or scripts. The API returns text and paths. The host decides how to pass that text to a model and whether to execute a packaged script. + ## Use native paths `Plugin`, `Skill`, `Manifest`, and `MCPConfig` implement the native filesystem protocol: diff --git a/docs/guide/validation.md b/docs/guide/validation.md index cac6d95..5dc86ba 100644 --- a/docs/guide/validation.md +++ b/docs/guide/validation.md @@ -48,6 +48,10 @@ A `Plugin` or `Skill` handle captures its selected file inventory during constru Each manifest, MCP configuration, and skill document reads content on first content access. The object then caches the loaded value or raised exception under a lock. Skill `source`, `frontmatter`, and `body` share one cached read. +An interrupted first read can be retried on the same handle. Completed reads and validation failures remain cached. + +Documents obtained through a `Plugin` or `Skill` recheck containment before that first read. If the selected path has become unavailable or resolves outside its root, content access raises a cached `ValidationError`. + Create a new handle to refresh document contents: ```python diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7f7a263..95f35e6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -126,7 +126,7 @@ The command uses tabs for indentation and columns. An environment with no marked The `skills` field contains absolute `SKILL.md` paths. An empty result is `[]`. -`list` is fail-fast. One marked distribution with unusable metadata or selected files stops the complete scan. +`list` follows Python metadata discovery precedence for equivalent distribution names, matching `locate`. It is fail-fast. A selected marked distribution with unusable metadata or files stops the complete scan. ## Output and exit statuses diff --git a/docs/reference/pyproject.md b/docs/reference/pyproject.md index a2cf578..f957318 100644 --- a/docs/reference/pyproject.md +++ b/docs/reference/pyproject.md @@ -55,7 +55,7 @@ The final `BuildPlan.files` tuple is sorted by target POSIX path. Duplicate targ ## Source-distribution staging -When the Python project directory contains `.agent-plugin/plugin.json`, the planner selects `.agent-plugin/` as the source root. A build-backend adapter creates this reserved directory inside a source distribution so a wheel rebuilt from that artifact uses its staged plugin payload. +When the Python project directory contains `.agent-plugin/plugin.json`, the planner selects every regular file in `.agent-plugin/`. A build-backend adapter creates this reserved directory inside a source distribution to capture the authored selection. A wheel rebuilt from that artifact uses the captured files directly. Keep authored plugin files at the configured `root`. Treat `.agent-plugin/` as build-system staging. diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index f700f0d..f071b64 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -11,16 +11,27 @@ Import the top-level API as `agent_plugins`: import agent_plugins as ap ``` -Load the package-selected Agent Plugin and read one named skill: +The installed `agent-plugins` distribution includes a skill you can inspect: ```python -plugin = ap.Plugin.from_project(".") +plugin = ap.locate("agent-plugins") skill = plugin.skill("agent-plugins") print(skill.source) ``` The distribution supports Python 3.10 through 3.14 and ships a `py.typed` marker. +| Task | Entry point | +| --- | --- | +| Preview selected package files | [`build_plan()`](#build-planning) | +| Add a plugin to an existing wheel | [`attach_wheel()`](#wheel-attachment) | +| Find plugins in this environment | [`locate()` and `installed()`](#installed-discovery) | +| Inspect authored files | [`Plugin.from_project()`](#plugin-from-project-project) or [`Plugin(path)`](#plugin) | +| Read instructions and resources | [`Skill`](#skill) | +| Inspect plugin metadata | [`Manifest`](#manifest) | +| Inspect tool configuration and prepare a launch | [`MCPConfig`](#mcpconfig) | +| Wrap a Python build backend | [`BuildBackend`](#buildbackend) | + ## Build planning ### `build_plan(project=".")` @@ -60,6 +71,8 @@ class FileMapping: `source` is an absolute local path. `target` is relative to the plugin root inside an artifact. +For a supplied `BuildPlan`, `attach_wheel()` checks that every source is a readable regular file and that targets are unique portable paths. Targets must include `plugin.json`. NUL characters, escaping paths, and file-directory collisions raise `AgentPluginError` before rewriting. + ## Wheel attachment ### `attach_wheel(wheel, *, project=None, plan=None, output_dir=None)` @@ -139,7 +152,7 @@ Pass the Python distribution name used by `pip` and `importlib.metadata`. An emp def installed() -> dict[str, Plugin]: ... ``` -Returns marked distributions keyed by Python distribution name and sorted without regard to case. Unmarked distributions are skipped. +Returns marked distributions keyed by Python distribution name and sorted without regard to case. For equivalent distribution names, the first installation in Python metadata discovery order wins, matching `locate()`. An unmarked first installation shadows later installations of the same distribution. Discovery is fail-fast. An invalid marked distribution raises `AgentPluginError` and stops the scan. diff --git a/src/agent_plugins/_build/plan.py b/src/agent_plugins/_build/plan.py index 1e48078..8cef941 100644 --- a/src/agent_plugins/_build/plan.py +++ b/src/agent_plugins/_build/plan.py @@ -4,7 +4,7 @@ import sys from dataclasses import dataclass -from pathlib import Path, PurePosixPath +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import cast if sys.version_info >= (3, 11): @@ -38,7 +38,7 @@ def build_plan(project: str | Path = ".") -> BuildPlan: """Load project configuration and return its complete plugin file plan.""" try: project_path = Path(project).resolve() - except (OSError, RuntimeError) as error: + except (OSError, RuntimeError, ValueError) as error: raise AgentPluginError( f"Project path cannot be resolved: {project}. Check the path and retry." ) from error @@ -63,10 +63,11 @@ def build_plan(project: str | Path = ".") -> BuildPlan: staged = project_path / STAGED_ROOT configured = project_path / root_value - source_root = staged if (staged / "plugin.json").is_file() else configured + is_staged = (staged / "plugin.json").is_file() + source_root = staged if is_staged else configured try: root = source_root.resolve(strict=True) - except (OSError, RuntimeError) as error: + except (OSError, RuntimeError, ValueError) as error: raise AgentPluginError( f"Agent Plugin root cannot be resolved: {source_root}" ) from error @@ -75,10 +76,13 @@ def build_plan(project: str | Path = ".") -> BuildPlan: files: dict[PurePosixPath, Path] = {} _add_file(files, root, root / "plugin.json", required=True) - _add_tree(files, root, root / "skills") - _add_file(files, root, root / "mcp.json", required=False) - for pattern in include: - _add_pattern(files, root, pattern) + if is_staged: + _add_tree(files, root, root) + else: + _add_tree(files, root, root / "skills") + _add_file(files, root, root / "mcp.json", required=False) + for pattern in include: + _add_pattern(files, root, pattern) mappings = tuple( FileMapping(source=source, target=target) @@ -87,6 +91,58 @@ def build_plan(project: str | Path = ".") -> BuildPlan: return BuildPlan(project=project_path, root=root, files=mappings) +def validate_plan(plan: BuildPlan) -> tuple[PurePosixPath, ...]: + """Check that a plan can produce a discoverable plugin payload.""" + files: list[PurePosixPath] = [] + seen: set[PurePosixPath] = set() + for mapping in plan.files: + target = mapping.target + value = target.as_posix() + if ( + target.is_absolute() + or PureWindowsPath(value).drive + or not target.parts + or ".." in target.parts + or "\\" in value + or "\x00" in value + ): + raise AgentPluginError( + f"Plugin target must stay within the plugin root: {value!r}" + ) + if target in seen: + raise AgentPluginError(f"Plugin plan contains a duplicate target: {value}") + collision = next( + ( + existing + for existing in seen + if existing in target.parents or target in existing.parents + ), + None, + ) + if collision is not None: + raise AgentPluginError( + "Plugin plan contains file-directory target collisions: " + f"{collision.as_posix()} and {value}" + ) + seen.add(target) + try: + source = mapping.source.resolve(strict=True) + except (OSError, RuntimeError, ValueError) as error: + raise AgentPluginError( + f"Plugin file cannot be read: {mapping.source}. " + "Restore the file and retry." + ) from error + if not source.is_file(): + raise AgentPluginError( + f"Plugin source is not a file: {source}. " + "Select a regular file and retry." + ) + files.append(target) + if PurePosixPath("plugin.json") not in seen: + raise AgentPluginError("Plugin plan must include plugin.json") + return tuple(files) + + def _config(document: dict[str, object], pyproject: Path) -> dict[str, object]: tool = document.get("tool") if not isinstance(tool, dict): @@ -133,6 +189,8 @@ def _add_tree(files: dict[PurePosixPath, Path], root: Path, directory: Path) -> return if not directory.is_dir(): raise AgentPluginError(f"Expected a directory: {directory}") + if directory.is_symlink(): + raise AgentPluginError(f"Directory symlinks cannot be packaged: {directory}") for candidate in directory.rglob("*"): if candidate.is_symlink() and candidate.is_dir(): raise AgentPluginError( diff --git a/src/agent_plugins/_build/sdist.py b/src/agent_plugins/_build/sdist.py index 1d2ae39..10ac5bd 100644 --- a/src/agent_plugins/_build/sdist.py +++ b/src/agent_plugins/_build/sdist.py @@ -35,7 +35,7 @@ def _rewrite(source_path: Path, target_path: Path, plan: BuildPlan) -> None: with tarfile.open(source_path, "r:gz") as source: members = source.getmembers() root = _archive_root(members) - stage = f"{root}/{STAGED_ROOT}" + stage = PurePosixPath(root, STAGED_ROOT) with ( target_path.open("wb") as raw_target, @@ -47,7 +47,7 @@ def _rewrite(source_path: Path, target_path: Path, plan: BuildPlan) -> None: ) as target, ): for member in members: - if member.name == stage or member.name.startswith(f"{stage}/"): + if PurePosixPath(member.name).is_relative_to(stage): continue file_object = source.extractfile(member) if member.isfile() else None try: diff --git a/src/agent_plugins/_build/wheel.py b/src/agent_plugins/_build/wheel.py index 7f2796b..a5d0c78 100644 --- a/src/agent_plugins/_build/wheel.py +++ b/src/agent_plugins/_build/wheel.py @@ -2,27 +2,18 @@ from __future__ import annotations -import base64 -import copy -import csv -import hashlib -import io -import lzma import os +import shutil import stat import tempfile import zipfile -import zlib from contextlib import suppress from dataclasses import dataclass -from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import IO +from pathlib import Path, PurePosixPath from .._errors import AgentPluginError -from .._marker import MARKER_NAME, PluginMarker -from .plan import BuildPlan, build_plan - -_CHUNK_SIZE = 1024 * 1024 +from .plan import BuildPlan, build_plan, validate_plan +from .wheel_archive import rewrite_wheel @dataclass(frozen=True, slots=True) @@ -38,14 +29,6 @@ class WheelAttachment: removed_signatures: tuple[PurePosixPath, ...] -@dataclass(frozen=True, slots=True) -class _WheelLayout: - dist_info: PurePosixPath - plugin_root: PurePosixPath - replaced_existing_plugin: bool - removed_signatures: tuple[PurePosixPath, ...] - - def attach_wheel( wheel: str | Path, *, @@ -62,12 +45,12 @@ def attach_wheel( source, source_mode = _resolve_wheel(wheel) output = _resolve_output(source, output_dir) selected_plan = plan if plan is not None else build_plan(project or Path.cwd()) - files = _validate_plan(selected_plan) + files = validate_plan(selected_plan) temporary = _temporary_wheel(output) try: try: - layout = _rewrite( + layout = rewrite_wheel( source, temporary, plan=selected_plan, @@ -114,11 +97,11 @@ def attach_wheel( def _attach_editable_wheel(wheel: Path, plan: BuildPlan) -> None: """Attach an authored-root marker to an editable wheel.""" source, source_mode = _resolve_wheel(wheel) - _validate_plan(plan) + validate_plan(plan) temporary = _temporary_wheel(source) try: try: - _rewrite(source, temporary, plan=plan, editable_root=plan.root) + rewrite_wheel(source, temporary, plan=plan, editable_root=plan.root) temporary.chmod(source_mode) temporary.replace(source) except AgentPluginError: @@ -151,7 +134,7 @@ def _resolve_wheel(wheel: str | Path) -> tuple[Path, int]: try: path = candidate.resolve(strict=True) metadata = path.stat() - except (OSError, RuntimeError) as error: + except (OSError, RuntimeError, ValueError) as error: raise AgentPluginError( f"Wheel cannot be read: {candidate}. " "Check the path and file access, then retry." @@ -169,7 +152,7 @@ def _resolve_output(source: Path, output_dir: str | Path | None) -> Path: candidate = Path(output_dir).expanduser() try: directory = candidate.resolve(strict=True) - except (OSError, RuntimeError) as error: + except (OSError, RuntimeError, ValueError) as error: raise AgentPluginError( f"Output directory cannot be used: {candidate}. " "Create the directory and check its access, then retry." @@ -188,291 +171,6 @@ def _resolve_output(source: Path, output_dir: str | Path | None) -> Path: return output -def _validate_plan(plan: BuildPlan) -> tuple[PurePosixPath, ...]: - files: list[PurePosixPath] = [] - seen: set[PurePosixPath] = set() - for mapping in plan.files: - target = mapping.target - value = target.as_posix() - if ( - target.is_absolute() - or PureWindowsPath(value).drive - or not target.parts - or ".." in target.parts - or "\\" in value - ): - raise AgentPluginError( - f"Plugin target must stay within the plugin root: {value}" - ) - if target in seen: - raise AgentPluginError(f"Plugin plan contains a duplicate target: {value}") - collision = next( - ( - existing - for existing in seen - if existing in target.parents or target in existing.parents - ), - None, - ) - if collision is not None: - raise AgentPluginError( - "Plugin plan contains file-directory target collisions: " - f"{collision.as_posix()} and {value}" - ) - seen.add(target) - try: - source = mapping.source.resolve(strict=True) - except (OSError, RuntimeError) as error: - raise AgentPluginError( - f"Plugin file cannot be read: {mapping.source}. " - "Restore the file and retry." - ) from error - if not source.is_file(): - raise AgentPluginError( - f"Plugin source is not a file: {source}. " - "Select a regular file and retry." - ) - files.append(target) - return tuple(files) - - -def _rewrite( - source_path: Path, - target_path: Path, - *, - plan: BuildPlan, - editable_root: Path | None, -) -> _WheelLayout: - records: list[tuple[str, str, str]] = [] - - with ( - zipfile.ZipFile(source_path) as source, - zipfile.ZipFile(target_path, "w", compression=zipfile.ZIP_DEFLATED) as target, - ): - layout = _inspect_archive(source, source_path) - dist_info = layout.dist_info.as_posix() - plugin_directory = layout.plugin_root.as_posix() - record_path = f"{dist_info}/RECORD" - marker_path = f"{dist_info}/{MARKER_NAME}" - marker = PluginMarker( - root=str(editable_root) if editable_root is not None else plugin_directory, - files=tuple(mapping.target for mapping in plan.files), - ).dumps() - - target.comment = source.comment - for info in source.infolist(): - if _owned(info.filename, record_path, marker_path, plugin_directory): - continue - _copy_member(source, target, info, records) - - if editable_root is None: - for mapping in plan.files: - name = f"{plugin_directory}/{mapping.target.as_posix()}" - _write_file(target, name, mapping.source, records) - _write_bytes(target, marker_path, marker, records) - _write_record(target, record_path, records) - return layout - - -def _inspect_archive(archive: zipfile.ZipFile, path: Path) -> _WheelLayout: - names = _validate_members(archive) - candidates = tuple( - PurePosixPath(info.filename).parent - for info in archive.infolist() - if not info.is_dir() - and len(info.filename.split("/")) == 2 - and info.filename.split("/")[1] == "WHEEL" - and info.filename.split("/")[0].endswith(".dist-info") - ) - if len(candidates) != 1: - raise AgentPluginError( - f"Wheel must contain exactly one top-level .dist-info/WHEEL file: {path}. " - "Rebuild the wheel and retry." - ) - dist_info = candidates[0] - for required in ("METADATA", "RECORD"): - member = (dist_info / required).as_posix() - if member not in names: - raise AgentPluginError( - f"Wheel is missing {member}: {path}. Rebuild the wheel and retry." - ) - - plugin_root = PurePosixPath( - f"{dist_info.name.removesuffix('.dist-info')}.agent-plugin" - ) - marker = (dist_info / MARKER_NAME).as_posix() - plugin_name = plugin_root.as_posix() - replaced = marker in names or any( - name == plugin_name or name.startswith(f"{plugin_name}/") for name in names - ) - signatures = tuple( - signature - for signature in ( - dist_info / "RECORD.jws", - dist_info / "RECORD.p7s", - ) - if signature.as_posix() in names - ) - return _WheelLayout( - dist_info=dist_info, - plugin_root=plugin_root, - replaced_existing_plugin=replaced, - removed_signatures=signatures, - ) - - -def _validate_members(archive: zipfile.ZipFile) -> set[str]: - names: set[str] = set() - for info in archive.infolist(): - name = info.orig_filename - path = PurePosixPath(name) - windows_path = PureWindowsPath(name) - raw_parts = name.split("/") - if not name or not path.parts: - raise AgentPluginError( - "Wheel contains an empty archive member. Rebuild the wheel and retry." - ) - if ( - path.is_absolute() - or windows_path.drive - or ".." in path.parts - or "\\" in name - or "." in raw_parts - or "" in raw_parts[:-1] - ): - raise AgentPluginError( - f"Wheel contains an unsafe archive member: {name}. " - "Rebuild the wheel with relative POSIX paths and retry." - ) - if name in names: - raise AgentPluginError( - f"Wheel contains a duplicate archive member: {name}. " - "Rebuild the wheel with unique member names and retry." - ) - if info.flag_bits & 0x1: - raise AgentPluginError( - f"Wheel contains an encrypted archive member: {name}. " - "Rebuild the wheel without ZIP encryption and retry." - ) - names.add(name) - return names - - -def _owned( - name: str, - record_path: str, - marker_path: str, - plugin_directory: str, -) -> bool: - signed_records = {f"{record_path}.jws", f"{record_path}.p7s"} - return name in { - record_path, - marker_path, - plugin_directory, - *signed_records, - } or name.startswith(f"{plugin_directory}/") - - -def _copy_member( - source: zipfile.ZipFile, - target: zipfile.ZipFile, - info: zipfile.ZipInfo, - records: list[tuple[str, str, str]], -) -> None: - copied_info = copy.copy(info) - if info.is_dir(): - target.writestr(copied_info, b"") - return - try: - with ( - source.open(info) as input_file, - target.open(copied_info, "w") as output_file, - ): - digest, size = _copy(input_file, output_file) - except (EOFError, RuntimeError, lzma.LZMAError, zlib.error) as error: - raise AgentPluginError( - f"Wheel member cannot be read: {info.filename}. " - "Rebuild the wheel and retry." - ) from error - records.append((info.filename, _digest(digest), str(size))) - - -def _write_file( - archive: zipfile.ZipFile, - name: str, - source: Path, - records: list[tuple[str, str, str]], -) -> None: - try: - source_stat = source.stat() - mode = stat.S_IMODE(source_stat.st_mode) - info = _file_info(name, mode) - info.file_size = source_stat.st_size - with ( - source.open("rb") as input_file, - archive.open( - info, - "w", - force_zip64=source_stat.st_size >= zipfile.ZIP64_LIMIT, - ) as output_file, - ): - digest, size = _copy(input_file, output_file) - except OSError as error: - raise AgentPluginError( - f"Plugin file cannot be read: {source}. Restore the file and retry." - ) from error - except RuntimeError as error: - raise AgentPluginError( - f"Plugin file cannot be written to the wheel: {source}. " - "Check its size and retry." - ) from error - records.append((name, _digest(digest), str(size))) - - -def _write_bytes( - archive: zipfile.ZipFile, - name: str, - value: bytes, - records: list[tuple[str, str, str]], -) -> None: - archive.writestr(_file_info(name, 0o644), value) - records.append((name, _digest(hashlib.sha256(value).digest()), str(len(value)))) - - -def _write_record( - archive: zipfile.ZipFile, - record_path: str, - records: list[tuple[str, str, str]], -) -> None: - output = io.StringIO(newline="") - writer = csv.writer(output, lineterminator="\n") - writer.writerows([*records, (record_path, "", "")]) - archive.writestr(_file_info(record_path, 0o644), output.getvalue().encode()) - - -def _copy(source: IO[bytes], target: IO[bytes]) -> tuple[bytes, int]: - digest = hashlib.sha256() - size = 0 - while chunk := source.read(_CHUNK_SIZE): - target.write(chunk) - digest.update(chunk) - size += len(chunk) - return digest.digest(), size - - -def _digest(value: bytes) -> str: - encoded = base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - return f"sha256={encoded}" - - -def _file_info(name: str, mode: int) -> zipfile.ZipInfo: - info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) - info.compress_type = zipfile.ZIP_DEFLATED - info.create_system = 3 - info.external_attr = (stat.S_IFREG | mode) << 16 - return info - - def _temporary_wheel(output: Path) -> Path: try: with tempfile.NamedTemporaryFile( @@ -515,7 +213,7 @@ def _publish_reserved(temporary: Path, output: Path) -> None: temporary.open("rb") as source, os.fdopen(descriptor, "wb", closefd=False) as target, ): - _copy(source, target) + shutil.copyfileobj(source, target, length=1024 * 1024) target.flush() os.fsync(descriptor) if not os.path.samestat(os.fstat(descriptor), output.lstat()): diff --git a/src/agent_plugins/_build/wheel_archive.py b/src/agent_plugins/_build/wheel_archive.py new file mode 100644 index 0000000..47e8b1d --- /dev/null +++ b/src/agent_plugins/_build/wheel_archive.py @@ -0,0 +1,292 @@ +"""Wheel member validation, payload rewriting, and RECORD serialization.""" + +from __future__ import annotations + +import base64 +import copy +import csv +import hashlib +import io +import lzma +import stat +import zipfile +import zlib +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import IO + +from .._errors import AgentPluginError +from .._marker import MARKER_NAME, PluginMarker +from .plan import BuildPlan + +_CHUNK_SIZE = 1024 * 1024 + + +@dataclass(frozen=True, slots=True) +class _WheelLayout: + dist_info: PurePosixPath + plugin_root: PurePosixPath + replaced_existing_plugin: bool + removed_signatures: tuple[PurePosixPath, ...] + + +def rewrite_wheel( + source_path: Path, + target_path: Path, + *, + plan: BuildPlan, + editable_root: Path | None, +) -> _WheelLayout: + records: list[tuple[str, str, str]] = [] + + with ( + zipfile.ZipFile(source_path) as source, + zipfile.ZipFile(target_path, "w", compression=zipfile.ZIP_DEFLATED) as target, + ): + layout = _inspect_archive(source, source_path) + dist_info = layout.dist_info.as_posix() + plugin_directory = layout.plugin_root.as_posix() + record_path = f"{dist_info}/RECORD" + marker_path = f"{dist_info}/{MARKER_NAME}" + marker = PluginMarker( + root=str(editable_root) if editable_root is not None else plugin_directory, + files=tuple(mapping.target for mapping in plan.files), + ).dumps() + + target.comment = source.comment + for info in source.infolist(): + if _owned(info.filename, record_path, marker_path, plugin_directory): + continue + _copy_member(source, target, info, records) + + if editable_root is None: + for mapping in plan.files: + name = f"{plugin_directory}/{mapping.target.as_posix()}" + _write_file(target, name, mapping.source, records) + _write_bytes(target, marker_path, marker, records) + _write_record(target, record_path, records) + return layout + + +def _inspect_archive(archive: zipfile.ZipFile, path: Path) -> _WheelLayout: + names = _validate_members(archive) + candidates = tuple( + PurePosixPath(info.filename).parent + for info in archive.infolist() + if not info.is_dir() + and len(info.filename.split("/")) == 2 + and info.filename.split("/")[1] == "WHEEL" + and info.filename.split("/")[0].endswith(".dist-info") + ) + if len(candidates) != 1: + raise AgentPluginError( + f"Wheel must contain exactly one top-level .dist-info/WHEEL file: {path}. " + "Rebuild the wheel and retry." + ) + dist_info = candidates[0] + for required in ("METADATA", "RECORD"): + member = (dist_info / required).as_posix() + if member not in names: + raise AgentPluginError( + f"Wheel is missing {member}: {path}. Rebuild the wheel and retry." + ) + + plugin_root = PurePosixPath( + f"{dist_info.name.removesuffix('.dist-info')}.agent-plugin" + ) + record = (dist_info / "RECORD").as_posix() + marker = (dist_info / MARKER_NAME).as_posix() + plugin_name = plugin_root.as_posix() + installed_names = {_installation_path(name) for name in names} + for name in names: + installed = _installation_path(name) + if ( + installed != name + and not name.endswith("/") + and PurePosixPath(installed).is_relative_to(dist_info) + and not _owned(name, record, marker, plugin_name) + ): + raise AgentPluginError( + f"Wheel contains relocated distribution metadata: {name}. " + "Rebuild the wheel with one top-level metadata directory and retry." + ) + replaced = marker in installed_names or any( + name == plugin_name or name.startswith(f"{plugin_name}/") + for name in installed_names + ) + signatures = tuple( + PurePosixPath(name) + for name in sorted(names) + if _installation_path(name) in {f"{record}.jws", f"{record}.p7s"} + ) + return _WheelLayout( + dist_info=dist_info, + plugin_root=plugin_root, + replaced_existing_plugin=replaced, + removed_signatures=signatures, + ) + + +def _validate_members(archive: zipfile.ZipFile) -> set[str]: + names: set[str] = set() + for info in archive.infolist(): + name = info.orig_filename + path = PurePosixPath(name) + windows_path = PureWindowsPath(name) + raw_parts = name.split("/") + if not name or not path.parts: + raise AgentPluginError( + "Wheel contains an empty archive member. Rebuild the wheel and retry." + ) + if ( + path.is_absolute() + or windows_path.drive + or ".." in path.parts + or "\\" in name + or "." in raw_parts + or "" in raw_parts[:-1] + or "\x00" in name + ): + raise AgentPluginError( + f"Wheel contains an unsafe archive member: {name}. " + "Rebuild the wheel with relative POSIX paths and retry." + ) + if name in names: + raise AgentPluginError( + f"Wheel contains a duplicate archive member: {name}. " + "Rebuild the wheel with unique member names and retry." + ) + if info.flag_bits & 0x1: + raise AgentPluginError( + f"Wheel contains an encrypted archive member: {name}. " + "Rebuild the wheel without ZIP encryption and retry." + ) + names.add(name) + return names + + +def _owned( + name: str, + record_path: str, + marker_path: str, + plugin_directory: str, +) -> bool: + name = _installation_path(name) + signed_records = {f"{record_path}.jws", f"{record_path}.p7s"} + return name in { + record_path, + marker_path, + plugin_directory, + *signed_records, + } or name.startswith(f"{plugin_directory}/") + + +def _installation_path(name: str) -> str: + parts = name.split("/", 2) + if ( + len(parts) == 3 + and parts[0].endswith(".data") + and parts[1] in {"purelib", "platlib"} + ): + return parts[2] + return name + + +def _copy_member( + source: zipfile.ZipFile, + target: zipfile.ZipFile, + info: zipfile.ZipInfo, + records: list[tuple[str, str, str]], +) -> None: + copied_info = copy.copy(info) + if info.is_dir(): + target.writestr(copied_info, b"") + return + try: + with ( + source.open(info) as input_file, + target.open(copied_info, "w") as output_file, + ): + digest, size = _copy(input_file, output_file) + except (EOFError, RuntimeError, lzma.LZMAError, zlib.error) as error: + raise AgentPluginError( + f"Wheel member cannot be read: {info.filename}. " + "Rebuild the wheel and retry." + ) from error + records.append((info.filename, _digest(digest), str(size))) + + +def _write_file( + archive: zipfile.ZipFile, + name: str, + source: Path, + records: list[tuple[str, str, str]], +) -> None: + try: + source_stat = source.stat() + mode = stat.S_IMODE(source_stat.st_mode) + info = _file_info(name, mode) + info.file_size = source_stat.st_size + with ( + source.open("rb") as input_file, + archive.open( + info, + "w", + force_zip64=source_stat.st_size >= zipfile.ZIP64_LIMIT, + ) as output_file, + ): + digest, size = _copy(input_file, output_file) + except OSError as error: + raise AgentPluginError( + f"Plugin file cannot be read: {source}. Restore the file and retry." + ) from error + except RuntimeError as error: + raise AgentPluginError( + f"Plugin file cannot be written to the wheel: {source}. " + "Check its size and retry." + ) from error + records.append((name, _digest(digest), str(size))) + + +def _write_bytes( + archive: zipfile.ZipFile, + name: str, + value: bytes, + records: list[tuple[str, str, str]], +) -> None: + archive.writestr(_file_info(name, 0o644), value) + records.append((name, _digest(hashlib.sha256(value).digest()), str(len(value)))) + + +def _write_record( + archive: zipfile.ZipFile, + record_path: str, + records: list[tuple[str, str, str]], +) -> None: + output = io.StringIO(newline="") + writer = csv.writer(output, lineterminator="\n") + writer.writerows([*records, (record_path, "", "")]) + archive.writestr(_file_info(record_path, 0o644), output.getvalue().encode()) + + +def _copy(source: IO[bytes], target: IO[bytes]) -> tuple[bytes, int]: + digest = hashlib.sha256() + size = 0 + while chunk := source.read(_CHUNK_SIZE): + target.write(chunk) + digest.update(chunk) + size += len(chunk) + return digest.digest(), size + + +def _digest(value: bytes) -> str: + encoded = base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + return f"sha256={encoded}" + + +def _file_info(name: str, mode: int) -> zipfile.ZipInfo: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = (stat.S_IFREG | mode) << 16 + return info diff --git a/src/agent_plugins/_discovery.py b/src/agent_plugins/_discovery.py index 031b6ed..72a7c01 100644 --- a/src/agent_plugins/_discovery.py +++ b/src/agent_plugins/_discovery.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from importlib import metadata from pathlib import Path @@ -33,12 +34,19 @@ def locate(distribution_name: str) -> Plugin: def installed() -> dict[str, Plugin]: """Return installed Agent Plugins keyed by distribution name.""" plugins: dict[str, Plugin] = {} + seen: set[str] = set() for distribution in metadata.distributions(): + name = distribution.metadata["Name"] + if name is not None: + normalized = re.sub(r"[-_.]+", "-", name).lower() + if normalized in seen: + continue + seen.add(normalized) + marker = distribution.read_text(MARKER_NAME) if marker is None: continue - name = distribution.metadata["Name"] if name is None: raise AgentPluginError( f"Distribution marker {MARKER_NAME!r} has no project name" diff --git a/src/agent_plugins/_files.py b/src/agent_plugins/_files.py index 20f5521..92473a3 100644 --- a/src/agent_plugins/_files.py +++ b/src/agent_plugins/_files.py @@ -130,7 +130,7 @@ def _resolve_root( candidate = Path(path) try: root = candidate.resolve(strict=True) - except (OSError, RuntimeError) as error: + except (OSError, RuntimeError, ValueError) as error: raise AgentPluginError( f"{kind} root cannot be resolved: {candidate}" ) from error diff --git a/src/agent_plugins/_mcp.py b/src/agent_plugins/_mcp.py new file mode 100644 index 0000000..495b92a --- /dev/null +++ b/src/agent_plugins/_mcp.py @@ -0,0 +1,198 @@ +"""Resolve MCP stdio declarations into subprocess inputs.""" + +from __future__ import annotations + +import os +import re +from collections.abc import Mapping +from pathlib import Path, PurePosixPath +from posixpath import normpath + +from ._errors import AgentPluginError +from ._files import FileInventory +from ._schema.models import MCPServer, ResolvedStdioServer, StdioServer + +_PLACEHOLDER = re.compile(r"\$\{PLUGIN_(ROOT|DATA)\}") + + +def resolve_stdio( + servers: Mapping[str, MCPServer], + name: str, + *, + root: Path, + inventory: FileInventory | None, + data_dir: str | os.PathLike[str], + base_env: Mapping[str, str] | None, +) -> ResolvedStdioServer: + """Resolve a stdio server against current filesystem and environment state.""" + server = servers.get(name) + if server is None: + available = ", ".join( + sorted(servers, key=lambda value: (value.casefold(), value)) + ) + raise AgentPluginError( + f"MCP server {name!r} is unavailable. " + f"Available servers: {available or 'none'}" + ) + if not isinstance(server, StdioServer): + raise AgentPluginError(f"MCP server {name!r} uses {server.type!r}, not 'stdio'") + + plugin_root = _plugin_directory(root) + data_root = _directory(data_dir, label="Plugin data directory") + roots = {"ROOT": str(plugin_root), "DATA": str(data_root)} + command = ( + str(_plugin_file(plugin_root, server.command, inventory)) + if server.command.startswith("./") + else server.command + ) + args = tuple(_expand(value, roots) for value in server.args) + + environment = dict(base_env or {}) + for key, value in server.env.items(): + _set_environment(environment, key, _expand(value, roots)) + _set_environment(environment, "PLUGIN_ROOT", str(plugin_root)) + _set_environment(environment, "PLUGIN_DATA", str(data_root)) + + cwd = _working_directory( + server.cwd, + plugin_root, + data_root, + roots, + inventory, + ) + return ResolvedStdioServer( + command=command, + args=args, + env=environment, + cwd=cwd, + ) + + +def _expand(value: str, roots: Mapping[str, str]) -> str: + return _PLACEHOLDER.sub(lambda match: roots[match.group(1)], value) + + +def _directory(path: str | os.PathLike[str], *, label: str) -> Path: + candidate = Path(path) + try: + resolved = candidate.resolve(strict=True) + except (OSError, RuntimeError, ValueError) as error: + raise AgentPluginError(f"{label} cannot be resolved: {candidate}") from error + if not resolved.is_dir(): + raise AgentPluginError(f"{label} is not a directory: {resolved}") + return resolved + + +def _plugin_directory(root: Path) -> Path: + try: + resolved = root.resolve(strict=True) + except (OSError, RuntimeError, ValueError) as error: + raise AgentPluginError(f"Plugin root cannot be resolved: {root}") from error + if resolved != root or not root.is_dir(): + raise AgentPluginError(f"Plugin root changed after construction: {root}") + return root + + +def _plugin_file( + root: Path, + command: str, + inventory: FileInventory | None, +) -> Path: + relative = PurePosixPath(command.removeprefix("./").replace("\\", "/")) + if inventory is not None: + try: + candidate = root.joinpath(*relative.parts) + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + selected_name = _selected_name(root, candidate, resolved) + selected = inventory.file(selected_name, kind="Agent Plugin") + if selected.resolve(strict=True) != resolved: + raise AgentPluginError("Command changed during selected-file lookup") + except (AgentPluginError, OSError, RuntimeError, ValueError) as error: + raise AgentPluginError( + f"MCP stdio command is unavailable in the selected plugin: {command}" + ) from error + if not resolved.is_file(): + raise AgentPluginError( + f"MCP stdio command is unavailable in the selected plugin: {command}" + ) + return resolved + candidate = root.joinpath(*relative.parts) + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as error: + raise AgentPluginError( + f"MCP stdio command cannot be resolved inside the plugin root: {command}" + ) from error + if not resolved.is_file(): + raise AgentPluginError(f"MCP stdio command is not a regular file: {candidate}") + return resolved + + +def _working_directory( + value: str | None, + plugin_root: Path, + data_root: Path, + roots: Mapping[str, str], + inventory: FileInventory | None, +) -> Path: + if value is None: + return plugin_root + expanded = _expand(value.replace("\\", "/"), roots) + if value.startswith("./"): + candidate = plugin_root / Path(expanded) + root = plugin_root + plugin_scoped = True + elif value == "${PLUGIN_ROOT}" or value.startswith("${PLUGIN_ROOT}/"): + candidate = Path(expanded) + root = plugin_root + plugin_scoped = True + elif value == "${PLUGIN_DATA}" or value.startswith("${PLUGIN_DATA}/"): + candidate = Path(expanded) + root = data_root + plugin_scoped = False + else: + raise AgentPluginError(f"Invalid MCP stdio working directory: {value}") + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + relative = ( + _selected_name(plugin_root, candidate, resolved) + if inventory is not None and plugin_scoped + else None + ) + except (OSError, RuntimeError, ValueError) as error: + raise AgentPluginError( + f"MCP stdio working directory cannot be resolved inside {root}: {value}" + ) from error + if not resolved.is_dir(): + raise AgentPluginError( + f"MCP stdio working directory is not a directory: {candidate}" + ) + if ( + inventory is not None + and relative is not None + and not any(name.is_relative_to(relative) for name in inventory.names) + ): + raise AgentPluginError( + "MCP stdio working directory is unavailable in the selected plugin: " + f"{value}" + ) + return resolved + + +def _selected_name(root: Path, candidate: Path, resolved: Path) -> PurePosixPath: + relative = PurePosixPath(normpath(candidate.relative_to(root).as_posix())) + if root.joinpath(*relative.parts).resolve(strict=False) != resolved: + return PurePosixPath(resolved.relative_to(root).as_posix()) + return relative + + +def _set_environment(environment: dict[str, str], name: str, value: str) -> None: + if os.name == "nt": + normalized = name.upper() + for existing in tuple(environment): + if existing.upper() == normalized: + del environment[existing] + environment[name] = value diff --git a/src/agent_plugins/_plugin.py b/src/agent_plugins/_plugin.py index ce46a5d..625bfb8 100644 --- a/src/agent_plugins/_plugin.py +++ b/src/agent_plugins/_plugin.py @@ -10,6 +10,7 @@ from ._errors import AgentPluginError from ._files import FileInventory from ._schema import Manifest, MCPConfig +from ._schema.skill import SkillDocument from ._skill import Skill from ._tree import DEFAULT_MAX_DEPTH, DEFAULT_MAX_FILES, render_tree @@ -165,7 +166,7 @@ def _set_state( inventory: FileInventory, ) -> None: plugin._inventory = inventory - manifest = Manifest(inventory.root / "plugin.json") + manifest = Manifest._from_inventory(inventory) mcp = ( MCPConfig._from_inventory(inventory.root / "mcp.json", manifest, inventory) if PurePosixPath("mcp.json") in inventory.names @@ -187,6 +188,12 @@ def _skills(inventory: FileInventory) -> tuple[tuple[str, Skill], ...]: and relative.name == "SKILL.md" ) return tuple( - (skill_root.name, Skill._from_inventory(inventory.subtree(skill_root))) + ( + skill_root.name, + Skill._from_inventory( + inventory.subtree(skill_root), + document=SkillDocument(inventory, (skill_root / "SKILL.md").as_posix()), + ), + ) for skill_root in skill_roots ) diff --git a/src/agent_plugins/_schema/json.py b/src/agent_plugins/_schema/json.py index c8bc9c6..f6bd6df 100644 --- a/src/agent_plugins/_schema/json.py +++ b/src/agent_plugins/_schema/json.py @@ -7,6 +7,7 @@ from pathlib import Path from .._errors import AgentPluginError +from .._files import FileInventory from .errors import ValidationError, ValidationIssue @@ -16,7 +17,7 @@ def resolve_file(path: str | os.PathLike[str]) -> Path: try: configured = candidate.parent.resolve(strict=True) / candidate.name resolved = configured.resolve(strict=True) - except (OSError, RuntimeError) as error: + except (OSError, RuntimeError, ValueError) as error: raise AgentPluginError( f"Plugin document cannot be resolved: {candidate}" ) from error @@ -34,6 +35,18 @@ def read_json(path: Path) -> object: raise validation_error(path, (), "Expected valid JSON") from error +def selected_file(inventory: FileInventory, name: str) -> Path: + """Recheck a selected document before its first content read.""" + try: + return inventory.file(name, kind="Plugin document") + except AgentPluginError as error: + raise validation_error( + inventory.root / name, + (), + "Document cannot be read inside its selected root", + ) from error + + def validation_error( path: Path, location: tuple[str | int, ...], diff --git a/src/agent_plugins/_schema/lazy.py b/src/agent_plugins/_schema/lazy.py index 7551eb8..4022191 100644 --- a/src/agent_plugins/_schema/lazy.py +++ b/src/agent_plugins/_schema/lazy.py @@ -43,5 +43,6 @@ def get(self) -> T: self._error = error raise finally: - self._loader = None + if self._value is not _UNSET or self._error is not _UNSET: + self._loader = None return self._value diff --git a/src/agent_plugins/_schema/manifest.py b/src/agent_plugins/_schema/manifest.py index 4bbfaa0..b8cd21e 100644 --- a/src/agent_plugins/_schema/manifest.py +++ b/src/agent_plugins/_schema/manifest.py @@ -7,8 +7,9 @@ from pathlib import Path from types import MappingProxyType +from .._files import FileInventory from .errors import ValidationIssue -from .json import read_json, resolve_file, validation_error +from .json import read_json, resolve_file, selected_file, validation_error from .lazy import LazyResult from .models import Author, ManifestData from .v1 import PLUGIN_SCHEMA_1_0_0 @@ -30,6 +31,14 @@ def __init__(self, path: str | os.PathLike[str]) -> None: self._path = resolved self._result = LazyResult(lambda: _load_manifest(resolved)) + @classmethod + def _from_inventory(cls, inventory: FileInventory) -> Manifest: + manifest = cls(inventory.root / "plugin.json") + manifest._result = LazyResult( + lambda: _load_manifest(selected_file(inventory, "plugin.json")) + ) + return manifest + @property def path(self) -> Path: """Return the absolute manifest path.""" diff --git a/src/agent_plugins/_schema/mcp.py b/src/agent_plugins/_schema/mcp.py index 1d47a25..cb3f120 100644 --- a/src/agent_plugins/_schema/mcp.py +++ b/src/agent_plugins/_schema/mcp.py @@ -3,19 +3,18 @@ from __future__ import annotations import os -import re from collections.abc import Callable, Mapping -from pathlib import Path, PurePosixPath +from pathlib import Path from types import MappingProxyType from typing import cast -from .._errors import AgentPluginError from .._files import FileInventory +from .._mcp import resolve_stdio from .errors import ValidationIssue -from .json import read_json, resolve_file, validation_error +from .json import read_json, resolve_file, selected_file, validation_error from .lazy import LazyResult from .manifest import Manifest -from .models import MCPData, MCPServer, ResolvedStdioServer, StdioServer +from .models import MCPData, MCPServer, ResolvedStdioServer from .v1 import MCP_SCHEMA_1_0_0, PLUGIN_SCHEMA_1_0_0 from .v1.mcp import load_mcp_v1 @@ -26,7 +25,6 @@ _MCP_LOADERS: Mapping[str, tuple[str, _MCPLoader]] = MappingProxyType( {MCP_SCHEMA_1_0_0: (PLUGIN_SCHEMA_1_0_0, load_mcp_v1)} ) -_PLACEHOLDER = re.compile(r"\$\{PLUGIN_(ROOT|DATA)\}") class MCPConfig: @@ -55,6 +53,11 @@ def _from_inventory( ) -> MCPConfig: config = cls(path, manifest) config._inventory = inventory + config._result = LazyResult( + lambda: _load_mcp( + config.path, manifest, inventory.root, inventory=inventory + ) + ) return config @property @@ -85,49 +88,13 @@ def resolve_stdio( base_env: Mapping[str, str] | None = None, ) -> ResolvedStdioServer: """Resolve one stdio server into subprocess inputs.""" - servers = self.servers - server = servers.get(name) - if server is None: - available = ", ".join( - sorted(servers, key=lambda value: (value.casefold(), value)) - ) - raise AgentPluginError( - f"MCP server {name!r} is unavailable. " - f"Available servers: {available or 'none'}" - ) - if not isinstance(server, StdioServer): - raise AgentPluginError( - f"MCP server {name!r} uses {server.type!r}, not 'stdio'" - ) - - plugin_root = _plugin_directory(self._root) - data_root = _directory(data_dir, label="Plugin data directory") - roots = {"ROOT": str(plugin_root), "DATA": str(data_root)} - command = ( - str(_plugin_file(plugin_root, server.command, self._inventory)) - if server.command.startswith("./") - else server.command - ) - args = tuple(_expand(value, roots) for value in server.args) - - environment = dict(base_env or {}) - for key, value in server.env.items(): - _set_environment(environment, key, _expand(value, roots)) - _set_environment(environment, "PLUGIN_ROOT", str(plugin_root)) - _set_environment(environment, "PLUGIN_DATA", str(data_root)) - - cwd = _working_directory( - server.cwd, - plugin_root, - data_root, - roots, - self._inventory, - ) - return ResolvedStdioServer( - command=command, - args=args, - env=environment, - cwd=cwd, + return resolve_stdio( + self.servers, + name, + root=self._root, + inventory=self._inventory, + data_dir=data_dir, + base_env=base_env, ) @property @@ -147,9 +114,15 @@ def __repr__(self) -> str: return f"MCPConfig(path={self.path!r})" -def _load_mcp(path: Path, manifest: Manifest, root: Path) -> MCPData: +def _load_mcp( + path: Path, + manifest: Manifest, + root: Path, + *, + inventory: FileInventory | None = None, +) -> MCPData: manifest_schema = manifest.schema - value = read_json(path) + value = read_json(selected_file(inventory, "mcp.json") if inventory else path) if not isinstance(value, dict): raise validation_error(path, (), "Expected an object") schema = value.get("$schema") @@ -169,115 +142,3 @@ def _load_mcp(path: Path, manifest: Manifest, root: Path) -> MCPData: servers=MappingProxyType(servers), issues=issues, ) - - -def _expand(value: str, roots: Mapping[str, str]) -> str: - return _PLACEHOLDER.sub(lambda match: roots[match.group(1)], value) - - -def _directory(path: str | os.PathLike[str], *, label: str) -> Path: - candidate = Path(path) - try: - resolved = candidate.resolve(strict=True) - except (OSError, RuntimeError) as error: - raise AgentPluginError(f"{label} cannot be resolved: {candidate}") from error - if not resolved.is_dir(): - raise AgentPluginError(f"{label} is not a directory: {resolved}") - return resolved - - -def _plugin_directory(root: Path) -> Path: - try: - resolved = root.resolve(strict=True) - except (OSError, RuntimeError) as error: - raise AgentPluginError(f"Plugin root cannot be resolved: {root}") from error - if resolved != root or not root.is_dir(): - raise AgentPluginError(f"Plugin root changed after construction: {root}") - return root - - -def _plugin_file( - root: Path, - command: str, - inventory: FileInventory | None, -) -> Path: - relative = PurePosixPath(command.removeprefix("./").replace("\\", "/")) - if inventory is not None: - try: - candidate = inventory.file(relative, kind="Agent Plugin") - resolved = candidate.resolve(strict=True) - resolved.relative_to(root) - except (AgentPluginError, OSError, RuntimeError, ValueError) as error: - raise AgentPluginError( - f"MCP stdio command is unavailable in the selected plugin: {command}" - ) from error - if not resolved.is_file(): - raise AgentPluginError( - f"MCP stdio command is unavailable in the selected plugin: {command}" - ) - return resolved - candidate = root.joinpath(*relative.parts) - try: - resolved = candidate.resolve(strict=True) - resolved.relative_to(root) - except (OSError, RuntimeError, ValueError) as error: - raise AgentPluginError( - f"MCP stdio command cannot be resolved inside the plugin root: {command}" - ) from error - if not resolved.is_file(): - raise AgentPluginError(f"MCP stdio command is not a regular file: {candidate}") - return resolved - - -def _working_directory( - value: str | None, - plugin_root: Path, - data_root: Path, - roots: Mapping[str, str], - inventory: FileInventory | None, -) -> Path: - if value is None: - return plugin_root - expanded = _expand(value.replace("\\", "/"), roots) - if value.startswith("./"): - candidate = plugin_root / Path(expanded) - root = plugin_root - plugin_scoped = True - elif value == "${PLUGIN_ROOT}" or value.startswith("${PLUGIN_ROOT}/"): - candidate = Path(expanded) - root = plugin_root - plugin_scoped = True - elif value == "${PLUGIN_DATA}" or value.startswith("${PLUGIN_DATA}/"): - candidate = Path(expanded) - root = data_root - plugin_scoped = False - else: - raise AgentPluginError(f"Invalid MCP stdio working directory: {value}") - try: - resolved = candidate.resolve(strict=True) - resolved.relative_to(root) - except (OSError, RuntimeError, ValueError) as error: - raise AgentPluginError( - f"MCP stdio working directory cannot be resolved inside {root}: {value}" - ) from error - if not resolved.is_dir(): - raise AgentPluginError( - f"MCP stdio working directory is not a directory: {candidate}" - ) - if inventory is not None and plugin_scoped: - relative = PurePosixPath(resolved.relative_to(plugin_root).as_posix()) - if not any(name.is_relative_to(relative) for name in inventory.names): - raise AgentPluginError( - "MCP stdio working directory is unavailable in the selected plugin: " - f"{value}" - ) - return resolved - - -def _set_environment(environment: dict[str, str], name: str, value: str) -> None: - if os.name == "nt": - normalized = name.upper() - for existing in tuple(environment): - if existing.upper() == normalized: - del environment[existing] - environment[name] = value diff --git a/src/agent_plugins/_schema/skill.py b/src/agent_plugins/_schema/skill.py index a7da6d5..1e53a11 100644 --- a/src/agent_plugins/_schema/skill.py +++ b/src/agent_plugins/_schema/skill.py @@ -5,7 +5,9 @@ from dataclasses import dataclass from pathlib import Path +from .._files import FileInventory from .errors import ValidationError, ValidationIssue +from .json import selected_file from .lazy import LazyResult @@ -21,9 +23,9 @@ class SkillDocument: __slots__ = ("_path", "_result") - def __init__(self, path: Path) -> None: - self._path = path - self._result = LazyResult(lambda: _load(path)) + def __init__(self, inventory: FileInventory, name: str = "SKILL.md") -> None: + self._path = inventory.root / name + self._result = LazyResult(lambda: _load(selected_file(inventory, name))) @property def frontmatter(self) -> str: diff --git a/src/agent_plugins/_skill.py b/src/agent_plugins/_skill.py index 6373c0b..292b139 100644 --- a/src/agent_plugins/_skill.py +++ b/src/agent_plugins/_skill.py @@ -32,9 +32,11 @@ def __init__(self, path: str | os.PathLike[str]) -> None: _set_state(self, inventory) @classmethod - def _from_inventory(cls, inventory: FileInventory) -> Skill: + def _from_inventory( + cls, inventory: FileInventory, *, document: SkillDocument | None = None + ) -> Skill: skill = object.__new__(cls) - _set_state(skill, inventory) + _set_state(skill, inventory, document=document) return skill @property @@ -121,6 +123,8 @@ def __hash__(self) -> int: def _set_state( skill: Skill, inventory: FileInventory, + *, + document: SkillDocument | None = None, ) -> None: skill._inventory = inventory - skill._document = SkillDocument(inventory.root / _INSTRUCTIONS) + skill._document = document if document is not None else SkillDocument(inventory) diff --git a/tests/test_build_backends.py b/tests/test_build_backends.py index 1bd4467..63546c0 100644 --- a/tests/test_build_backends.py +++ b/tests/test_build_backends.py @@ -1,8 +1,5 @@ from __future__ import annotations -import base64 -import csv -import hashlib import importlib import json import stat @@ -12,6 +9,7 @@ from typing import Protocol, cast import pytest +from wheel_assertions import assert_wheel_record import agent_plugins as ap from agent_plugins._build.sdist import write_sdist_plugin @@ -94,7 +92,7 @@ def test_backend_builds_regular_sdist_and_editable_plugins( name.startswith("demo_provider-1.2.3.agent-plugin/") for name in archive.namelist() ) - _assert_record(editable) + assert_wheel_record(editable) def test_sdist_rewrite_preserves_outer_mode(tmp_path: Path) -> None: @@ -112,6 +110,39 @@ def test_sdist_rewrite_preserves_outer_mode(tmp_path: Path) -> None: assert stat.S_IMODE(source.stat().st_mode) == source_mode +def test_sdist_rewrite_replaces_the_canonical_staged_inventory( + tmp_path: Path, +) -> None: + project, _root = _project(tmp_path, "uv_build") + source = tmp_path / "demo-provider-1.2.3.tar.gz" + archive_root = "demo-provider-1.2.3" + content = tmp_path / "content.txt" + content.write_text("source bytes\n", encoding="utf-8") + stage = f"./{archive_root}//.agent-plugin" + with tarfile.open(source, "w:gz") as archive: + archive.add(content, f"{stage}/skills/previous/SKILL.md") + archive.add(content, f"./{archive_root}/original.txt") + archive.add(project / "pyproject.toml", f"{archive_root}/pyproject.toml") + + expected = ap.build_plan(project) + write_sdist_plugin(source, expected) + + extracted = tmp_path / "extracted" + with tarfile.open(source, "r:gz") as archive: + archive.extractall(extracted, filter="data") + rebuilt = ap.Plugin(extracted / archive_root / ".agent-plugin") + assert {path.relative_to(rebuilt.path).as_posix() for path in rebuilt.files} == { + "bin/server.py", + "mcp.json", + "plugin.json", + "skills/demo/SKILL.md", + "skills/demo/references/guide.md", + } + assert (extracted / archive_root / "original.txt").read_bytes() == ( + content.read_bytes() + ) + + def _assert_regular_wheel(wheel: Path, *, executable_mode: int) -> dict[str, bytes]: prefix = "demo_provider-1.2.3.agent-plugin" expected = { @@ -136,29 +167,10 @@ def _assert_regular_wheel(wheel: Path, *, executable_mode: int) -> dict[str, byt if name.startswith(f"{prefix}/") } assert payload.keys() == expected - _assert_record(wheel) + assert_wheel_record(wheel) return payload -def _assert_record(wheel: Path) -> None: - with zipfile.ZipFile(wheel) as archive: - dist_info = _dist_info(archive) - record_name = f"{dist_info}/RECORD" - rows = list(csv.reader(archive.read(record_name).decode().splitlines())) - files = {name for name in archive.namelist() if not name.endswith("/")} - row_names = [row[0] for row in rows] - assert len(row_names) == len(set(row_names)) - assert set(row_names) == files - for name, digest, size in rows: - if name == record_name: - assert (digest, size) == ("", "") - continue - value = archive.read(name) - encoded = base64.urlsafe_b64encode(hashlib.sha256(value).digest()) - assert digest == f"sha256={encoded.rstrip(b'=').decode()}" - assert size == str(len(value)) - - def _dist_info(archive: zipfile.ZipFile) -> str: return next( name.removesuffix("/WHEEL") @@ -198,7 +210,7 @@ def _project(tmp_path: Path, backend: str) -> tuple[Path, Path]: [tool.agent-plugins] root = "../.." -include = ["bin/**"] +include = ["bin/**", "empty-assets/**"] """, encoding="utf-8", ) @@ -222,6 +234,7 @@ def _project(tmp_path: Path, backend: str) -> tuple[Path, Path]: (skill / "references" / "guide.md").write_text("# Guide\n", encoding="utf-8") binary = root / "bin" binary.mkdir() + (root / "empty-assets").mkdir() server = binary / "server.py" server.write_text("print('demo')\n", encoding="utf-8") server.chmod(0o755) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7e38b70..c7836f5 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -2,7 +2,7 @@ import json import os -from importlib import metadata +import sys from pathlib import Path import pytest @@ -11,19 +11,61 @@ from agent_plugins._cli import main -def test_locate_returns_the_installed_plugin( +@pytest.mark.parametrize( + ("first_name", "second_name"), + [("demo-provider", "demo-provider"), ("Demo.Provider", "demo_provider")], +) +def test_installed_matches_locate_for_shadowed_distributions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + first_name: str, + second_name: str, +) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + first_root = _distribution(first, name=first_name) + _distribution(second, name=second_name) + monkeypatch.setattr(sys, "path", [str(first), str(second)]) + + assert ap.locate("demo-provider").path == first_root.resolve() + assert ap.installed() == {first_name: ap.locate("demo-provider")} + + +def test_unmarked_distribution_shadows_later_plugin( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - distribution, root = _distribution(tmp_path) - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, - ) + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + _distribution(first) + _distribution(second) + (first / "demo_provider-1.2.3.dist-info" / "agent_plugins.json").unlink() + monkeypatch.setattr(sys, "path", [str(first), str(second)]) - plugin = ap.locate("demo-provider") + with pytest.raises(ap.AgentPluginError, match="has no Agent Plugin"): + ap.locate("demo-provider") + assert ap.installed() == {} + + +def test_installed_reports_invalid_marker_in_active_distribution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + _distribution(first) + _distribution(second) + (first / "demo_provider-1.2.3.dist-info" / "agent_plugins.json").write_text( + "{", encoding="utf-8" + ) + monkeypatch.setattr(sys, "path", [str(first), str(second)]) - assert plugin == ap.Plugin(root) - assert plugin.path == root.resolve() + with pytest.raises(ap.AgentPluginError, match=r"invalid agent_plugins\.json"): + ap.installed() def test_installed_and_list_json_expose_absolute_skill_paths( @@ -31,19 +73,14 @@ def test_installed_and_list_json_expose_absolute_skill_paths( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - distribution, root = _distribution( - tmp_path, files=("plugin.json", "skills/demo/SKILL.md") - ) + root = _distribution(tmp_path, files=("plugin.json", "skills/demo/SKILL.md")) skill = root / "skills" / "demo" / "SKILL.md" skill.parent.mkdir(parents=True) skill.write_text( "---\nname: demo\ndescription: Demonstrate the package\n---\n# Demo\n", encoding="utf-8", ) - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distributions", - lambda: iter((distribution,)), - ) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) assert ap.installed() == {"demo-provider": ap.Plugin(root)} assert main(["list", "--json"]) == 0 @@ -57,10 +94,10 @@ def test_installed_and_list_json_expose_absolute_skill_paths( ] -def test_locate_limits_each_skill_to_packaged_files( +def test_locate_limits_plugin_and_skill_to_packaged_files( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - distribution, root = _distribution( + root = _distribution( tmp_path, files=("plugin.json", "skills/demo/SKILL.md"), ) @@ -73,22 +110,30 @@ def test_locate_limits_each_skill_to_packaged_files( encoding="utf-8", ) (references / "local.md").write_text("# Local\n", encoding="utf-8") - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, - ) + (root / "README.md").write_text("# Repository\n", encoding="utf-8") + monkeypatch.setattr(sys, "path", [str(tmp_path)]) plugin = ap.locate("demo-provider") - assert len(plugin.skills) == 1 - assert plugin.skills[0].files == (instructions.resolve(),) - assert "local.md" not in plugin.skills[0].tree() + assert plugin.files == ((root / "plugin.json").resolve(), instructions.resolve()) + skill = plugin.skill("demo") + assert skill.files == (instructions.resolve(),) + assert skill.tree() == f"{skill.path}{os.sep}\n`-- SKILL.md" + assert plugin.tree() == "\n".join( + ( + f"{root.resolve()}{os.sep}", + "|-- plugin.json", + "`-- skills/", + " `-- demo/", + " `-- SKILL.md", + ) + ) def test_locate_preserves_structural_name_for_contained_skill_alias( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - distribution, root = _distribution( + root = _distribution( tmp_path, files=("plugin.json", "skills/alias/SKILL.md"), ) @@ -103,10 +148,7 @@ def test_locate_preserves_structural_name_for_contained_skill_alias( (root / "skills" / "alias").symlink_to(target, target_is_directory=True) except OSError as error: pytest.skip(f"symlinks unavailable: {error}") - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, - ) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) plugin = ap.locate("demo-provider") skill = plugin.skill("alias") @@ -115,72 +157,45 @@ def test_locate_preserves_structural_name_for_contained_skill_alias( assert skill.file("SKILL.md") == instructions.resolve() -def test_locate_limits_the_tree_to_packaged_plugin_files( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - distribution, root = _distribution(tmp_path) - (root / "README.md").write_text("# Repository\n", encoding="utf-8") - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, - ) - - plugin = ap.locate("demo-provider") - - assert plugin.files == ((root / "plugin.json").resolve(),) - assert plugin.tree() == f"{root.resolve()}{os.sep}\n`-- plugin.json" - - def test_locate_command_prints_the_absolute_root_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - distribution, root = _distribution(tmp_path) - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, - ) + root = _distribution(tmp_path) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) assert main(["locate", "demo-provider"]) == 0 assert capsys.readouterr().out == f"{root.resolve()}\n" -def test_locate_reports_a_distribution_without_a_plugin( +def test_locate_tells_the_user_to_reinstall_outdated_metadata( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - dist_info = tmp_path / "demo_provider-1.2.3.dist-info" - dist_info.mkdir() - (dist_info / "METADATA").write_text( - "Metadata-Version: 2.4\nName: demo-provider\nVersion: 1.2.3\n", - encoding="utf-8", - ) - distribution = metadata.PathDistribution(dist_info) - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, + root = _distribution(tmp_path) + (tmp_path / "demo_provider-1.2.3.dist-info" / "agent_plugins.json").write_text( + json.dumps({"root": root.name}), encoding="utf-8" ) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) - with pytest.raises(ap.AgentPluginError, match="has no Agent Plugin"): + with pytest.raises( + ap.AgentPluginError, + match=r"outdated Agent Plugin metadata.*Reinstall the distribution", + ): ap.locate("demo-provider") -def test_locate_tells_the_user_to_reinstall_outdated_metadata( +def test_locate_reports_unusable_marker_root( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - distribution, root = _distribution(tmp_path) + _distribution(tmp_path) (tmp_path / "demo_provider-1.2.3.dist-info" / "agent_plugins.json").write_text( - json.dumps({"root": root.name}), encoding="utf-8" - ) - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, + json.dumps({"root": "bad\0root", "files": ["plugin.json"]}), + encoding="utf-8", ) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) - with pytest.raises( - ap.AgentPluginError, - match=r"outdated Agent Plugin metadata.*Reinstall the distribution", - ): + with pytest.raises(ap.AgentPluginError, match="root cannot be resolved"): ap.locate("demo-provider") @@ -197,15 +212,12 @@ def test_locate_reports_invalid_marker_data( monkeypatch: pytest.MonkeyPatch, marker: str, ) -> None: - distribution, _root = _distribution(tmp_path) + _distribution(tmp_path) (tmp_path / "demo_provider-1.2.3.dist-info" / "agent_plugins.json").write_text( marker, encoding="utf-8", ) - monkeypatch.setattr( - "agent_plugins._discovery.metadata.distribution", - lambda _name: distribution, - ) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) with pytest.raises( ap.AgentPluginError, @@ -215,12 +227,15 @@ def test_locate_reports_invalid_marker_data( def _distribution( - tmp_path: Path, *, files: tuple[str, ...] = ("plugin.json",) -) -> tuple[metadata.Distribution, Path]: + tmp_path: Path, + *, + files: tuple[str, ...] = ("plugin.json",), + name: str = "demo-provider", +) -> Path: dist_info = tmp_path / "demo_provider-1.2.3.dist-info" dist_info.mkdir() (dist_info / "METADATA").write_text( - "Metadata-Version: 2.4\nName: demo-provider\nVersion: 1.2.3\n", + f"Metadata-Version: 2.4\nName: {name}\nVersion: 1.2.3\n", encoding="utf-8", ) root = tmp_path / "demo_provider-1.2.3.agent-plugin" @@ -229,4 +244,4 @@ def _distribution( (dist_info / "agent_plugins.json").write_text( json.dumps({"root": root.name, "files": list(files)}), encoding="utf-8" ) - return metadata.PathDistribution(dist_info), root + return root diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 38a3c4f..b8447b0 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -31,6 +31,24 @@ def test_manifest_is_a_lazy_cached_file_backed_model(tmp_path: Path) -> None: assert manifest.description == "Cached description" +def test_manifest_can_retry_an_interrupted_first_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "plugin.json" + _write_manifest(path, name="demo") + manifest = ap.Manifest(path) + + def interrupt(_path: Path, **_kwargs: object) -> str: + raise KeyboardInterrupt + + with monkeypatch.context() as patch: + patch.setattr(Path, "read_text", interrupt) + with pytest.raises(KeyboardInterrupt): + _name = manifest.name + + assert manifest.name == "demo" + + def test_manifest_exposes_typed_immutable_values(tmp_path: Path) -> None: path = tmp_path / "plugin.json" path.write_text( @@ -121,10 +139,6 @@ def test_manifest_reports_and_ignores_nonfatal_fields(tmp_path: Path) -> None: ("value", "message"), [ ({"name": "demo-plugin"}, "Unsupported or missing manifest schema"), - ( - {"$schema": PLUGIN_SCHEMA, "name": "Demo"}, - "Invalid plugin name", - ), ( {"$schema": PLUGIN_SCHEMA, "name": "demo", "keywords": [1]}, "Expected an array of strings", @@ -189,7 +203,7 @@ def test_manifest_rejects_nonstandard_json_constants(tmp_path: Path) -> None: @pytest.mark.parametrize( "name", - ["a", "my-plugin", "acme.tools", "lint3r", "a" * 64], + ["a", "my-plugin.tools3", "a" * 64], ) def test_manifest_accepts_specification_names(tmp_path: Path, name: str) -> None: path = tmp_path / "plugin.json" @@ -204,8 +218,6 @@ def test_manifest_accepts_specification_names(tmp_path: Path, name: str) -> None "", "Demo", "-start", - ".start", - "end-", "end.", "has--double", "has..double", diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 86e2de4..78aff38 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -270,7 +270,6 @@ def test_mcp_caches_validation_failures(tmp_path: Path) -> None: @pytest.mark.parametrize( "url", [ - "https://example.com/mcp", "http://localhost/mcp", "http://127.0.0.1:8080/mcp", "http://[::1]/mcp", diff --git a/tests/test_mcp_resolution.py b/tests/test_mcp_resolution.py index bc392d5..627430f 100644 --- a/tests/test_mcp_resolution.py +++ b/tests/test_mcp_resolution.py @@ -120,7 +120,6 @@ def test_resolve_stdio_expansion_is_single_pass_and_leaves_keys_literal( assert launch.command == "${PLUGIN_ROOT}" assert launch.args == (str(data_dir.resolve()), "${UNKNOWN}") - assert "${PLUGIN_ROOT}" in launch.args[0] assert launch.env["${PLUGIN_ROOT}"] == str(data_dir.resolve()) @@ -153,12 +152,12 @@ def test_resolve_stdio_rejects_unknown_or_non_stdio_server( _mcp(root).resolve_stdio(name, data_dir=data_dir) -@pytest.mark.parametrize("data_kind", ["missing", "file"]) +@pytest.mark.parametrize("data_kind", ["missing", "file", "invalid"]) def test_resolve_stdio_requires_existing_data_directory( tmp_path: Path, data_kind: str ) -> None: root = _plugin(tmp_path, {"local": _stdio("python")}) - data_dir = tmp_path / "data" + data_dir = tmp_path / ("bad\0data" if data_kind == "invalid" else "data") if data_kind == "file": data_dir.write_text("data\n", encoding="utf-8") @@ -166,7 +165,7 @@ def test_resolve_stdio_requires_existing_data_directory( _mcp(root).resolve_stdio("local", data_dir=data_dir) -def test_resolve_stdio_rejects_unselected_plugin_command(tmp_path: Path) -> None: +def test_resolve_stdio_requires_existing_plugin_command(tmp_path: Path) -> None: root = _plugin(tmp_path, {"local": _stdio("./bin/missing")}) data_dir = tmp_path / "data" data_dir.mkdir() @@ -292,6 +291,50 @@ def test_resolve_stdio_rejects_unselected_project_paths(tmp_path: Path) -> None: mcp.resolve_stdio("local", data_dir=data_dir) +@pytest.mark.parametrize("cwd", ["./alias", "${PLUGIN_ROOT}/work/../alias"]) +def test_resolve_stdio_uses_selected_working_directory_alias( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cwd: str +) -> None: + root = _plugin( + tmp_path, + { + "local": _stdio("python", cwd=cwd), + "unselected": _stdio("python", cwd="./other"), + }, + ) + target = root / "target" + target.mkdir() + (target / "script.py").write_text("print('demo')\n", encoding="utf-8") + (root / "work").mkdir() + try: + (root / "alias").symlink_to(target, target_is_directory=True) + (root / "other").symlink_to(target, target_is_directory=True) + except OSError as error: + pytest.skip(f"symlinks unavailable: {error}") + dist_info = tmp_path / "demo_provider-1.0.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + "Metadata-Version: 2.4\nName: demo-provider\nVersion: 1.0\n", + encoding="utf-8", + ) + (dist_info / "agent_plugins.json").write_text( + json.dumps( + { + "root": root.name, + "files": ["plugin.json", "mcp.json", "alias/script.py"], + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) + mcp = ap.locate("demo-provider").mcp + assert mcp is not None + + assert mcp.resolve_stdio("local", data_dir=tmp_path).cwd == target.resolve() + with pytest.raises(ap.AgentPluginError, match="unavailable in the selected plugin"): + mcp.resolve_stdio("unselected", data_dir=tmp_path) + + def test_direct_mcp_config_resolves_contained_physical_paths(tmp_path: Path) -> None: root = _plugin( tmp_path, @@ -335,6 +378,70 @@ def test_plugin_relative_command_accepts_portable_backslash_separator( assert launch.command == str(server.resolve()) +def test_plugin_relative_command_resolves_contained_selected_paths( + tmp_path: Path, +) -> None: + root = _plugin(tmp_path, {"local": _stdio("./bin/../server")}) + (root / "bin").mkdir() + server = root / "server" + server.write_text("server\n", encoding="utf-8") + data_dir = tmp_path / "data" + data_dir.mkdir() + + launch = _mcp(root).resolve_stdio("local", data_dir=data_dir) + + assert launch.command == str(server.resolve()) + + +def test_plugin_relative_command_keeps_filesystem_traversal_semantics( + tmp_path: Path, +) -> None: + root = _plugin(tmp_path, {"local": _stdio("./alias/../server")}) + nested = root / "nested" / "bin" + nested.mkdir(parents=True) + try: + (root / "alias").symlink_to(nested, target_is_directory=True) + except OSError as error: + pytest.skip(f"symlinks unavailable: {error}") + (root / "nested" / "server").write_text("nested\n", encoding="utf-8") + data_dir = tmp_path / "data" + data_dir.mkdir() + + launch = _mcp(root).resolve_stdio("local", data_dir=data_dir) + + assert launch.command == str((root / "nested" / "server").resolve()) + + +@pytest.mark.parametrize("selected", ["alias", "server"]) +def test_plugin_relative_command_uses_selected_symlink_name( + tmp_path: Path, selected: str +) -> None: + root = _plugin(tmp_path, {"local": _stdio("./bin/../alias")}) + (root / "pyproject.toml").write_text( + f'[tool.agent-plugins]\nroot = "."\ninclude = ["{selected}"]\n', + encoding="utf-8", + ) + (root / "bin").mkdir() + server = root / "server" + server.write_text("server\n", encoding="utf-8") + try: + (root / "alias").symlink_to(server) + except OSError as error: + pytest.skip(f"symlinks unavailable: {error}") + data_dir = tmp_path / "data" + data_dir.mkdir() + mcp = ap.Plugin.from_project(root).mcp + assert mcp is not None + + if selected == "alias": + assert mcp.resolve_stdio("local", data_dir=data_dir).command == str( + server.resolve() + ) + else: + with pytest.raises(ap.AgentPluginError, match="selected plugin"): + mcp.resolve_stdio("local", data_dir=data_dir) + + @pytest.mark.skipif(os.name == "nt", reason="POSIX absolute path expansion case") def test_plugin_relative_cwd_keeps_expanded_root_relative(tmp_path: Path) -> None: parent = tmp_path / "parent\\part" diff --git a/tests/test_plan.py b/tests/test_plan.py index 112b833..cb57c1e 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -61,6 +61,37 @@ def test_include_pattern_must_match_a_plugin_file(tmp_path: Path) -> None: ap.build_plan(project) +def test_include_pattern_accepts_an_empty_directory(tmp_path: Path) -> None: + project, root = _project(tmp_path, include="assets/**") + (root / "assets").mkdir() + + plan = ap.build_plan(project) + + assert tuple(mapping.target.as_posix() for mapping in plan.files) == ( + "mcp.json", + "plugin.json", + "skills/demo/SKILL.md", + "skills/demo/references/guide.md", + ) + + +@pytest.mark.parametrize("tree", ["skills", "bin"]) +def test_build_plan_rejects_selected_directory_symlinks( + tmp_path: Path, tree: str +) -> None: + project, root = _project(tmp_path) + directory = root / tree + source = root / f"{tree}-source" + directory.rename(source) + try: + directory.symlink_to(source, target_is_directory=True) + except OSError as error: + pytest.skip(f"symlinks unavailable: {error}") + + with pytest.raises(ap.AgentPluginError, match="Directory symlinks"): + ap.build_plan(project) + + def test_plan_command_reports_configuration_errors( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -79,6 +110,23 @@ def test_build_plan_reports_invalid_utf8_as_agent_plugin_error(tmp_path: Path) - ap.build_plan(tmp_path) +@pytest.mark.parametrize("invalid_location", ["project", "root"]) +def test_build_plan_reports_invalid_filesystem_names( + tmp_path: Path, invalid_location: str +) -> None: + project = tmp_path + if invalid_location == "project": + project = tmp_path / "invalid\0project" + else: + (project / "pyproject.toml").write_text( + '[tool.agent-plugins]\nroot = "invalid\\u0000root"\n', + encoding="utf-8", + ) + + with pytest.raises(ap.AgentPluginError, match="cannot be resolved"): + ap.build_plan(project) + + def test_build_plan_reports_symlink_loop_as_agent_plugin_error(tmp_path: Path) -> None: project = tmp_path / "project" try: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 68926db..b11801d 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,8 +1,10 @@ from __future__ import annotations import os +from collections.abc import Callable from html import escape from pathlib import Path +from typing import cast import pytest @@ -30,13 +32,11 @@ def test_plugin_exposes_absolute_component_paths(tmp_path: Path) -> None: ) ) assert plugin.skills == (ap.Skill(root / "skills" / "demo"),) - assert plugin.skills is plugin.skills assert plugin.skills[0].path == (root / "skills" / "demo").resolve() assert isinstance(plugin.mcp, ap.MCPConfig) assert plugin.mcp.path == (root / "mcp.json").resolve() assert plugin.manifest is plugin.manifest assert plugin.mcp is plugin.mcp - assert os.fspath(plugin) == str(root.resolve()) assert Path(plugin) == root.resolve() @@ -118,6 +118,14 @@ def test_plugin_requires_a_manifest(tmp_path: Path) -> None: ap.Plugin(tmp_path) +@pytest.mark.parametrize("constructor", [ap.Plugin, ap.Manifest]) +def test_plugin_paths_report_invalid_filesystem_names( + tmp_path: Path, constructor: Callable[[Path], object] +) -> None: + with pytest.raises(ap.AgentPluginError, match="cannot be resolved"): + constructor(tmp_path / "invalid\0path") + + def test_plugin_reports_a_file_as_an_invalid_root(tmp_path: Path) -> None: path = tmp_path / "plugin.json" path.write_text("{}\n", encoding="utf-8") @@ -133,6 +141,100 @@ def test_plugin_mcp_is_none_when_configuration_is_absent(tmp_path: Path) -> None assert ap.Plugin(root).mcp is None +@pytest.mark.parametrize( + ("relative", "read"), + [ + ("plugin.json", lambda plugin: plugin.manifest.name), + ("mcp.json", lambda plugin: cast(ap.MCPConfig, plugin.mcp).servers), + ("skills/demo/SKILL.md", lambda plugin: plugin.skill("demo").source), + ], +) +def test_plugin_document_reads_recheck_containment( + tmp_path: Path, relative: str, read: Callable[[ap.Plugin], object] +) -> None: + _project_path, root = _project(tmp_path) + document = root / relative + source = document.read_text(encoding="utf-8") + plugin = ap.Plugin(root) + outside = tmp_path / document.name + outside.write_text(source, encoding="utf-8") + document.unlink() + try: + document.symlink_to(outside) + except OSError as error: + pytest.skip(f"symlinks unavailable: {error}") + + with pytest.raises(ap.ValidationError) as first: + read(plugin) + + assert first.value.path == plugin.path / relative + document.unlink() + document.write_text(source, encoding="utf-8") + with pytest.raises(ap.ValidationError) as cached: + read(plugin) + assert cached.value is first.value + assert read(ap.Plugin(root)) is not None + + +@pytest.mark.parametrize( + ("relative", "read"), + [ + ("plugin.json", lambda plugin: plugin.manifest.name), + ("mcp.json", lambda plugin: cast(ap.MCPConfig, plugin.mcp).servers), + ("skills/demo/SKILL.md", lambda plugin: plugin.skill("demo").source), + ], +) +def test_plugin_documents_read_contained_symlinks_lazily( + tmp_path: Path, relative: str, read: Callable[[ap.Plugin], object] +) -> None: + _project_path, root = _project(tmp_path) + document = root / relative + target = document.with_name(f"content-{document.name}") + document.rename(target) + try: + document.symlink_to(target) + except OSError as error: + pytest.skip(f"symlinks unavailable: {error}") + plugin = ap.Plugin(root) + target.write_text( + target.read_text(encoding="utf-8").replace("demo", "revised"), + encoding="utf-8", + ) + + content = read(plugin) + + assert "revised" in cast(str | dict[str, object], content) + + +def test_plugin_skill_document_uses_plugin_containment(tmp_path: Path) -> None: + _project_path, root = _project(tmp_path) + instructions = root / "skills" / "demo" / "SKILL.md" + source = instructions.read_text(encoding="utf-8") + shared = root / "shared" + shared.mkdir() + target = shared / "instructions.md" + instructions.rename(target) + try: + instructions.symlink_to(target) + except OSError as error: + pytest.skip(f"symlinks unavailable: {error}") + + skill = ap.Plugin(root).skill("demo") + assert skill.source == source + with pytest.raises(ap.AgentPluginError): + skill.file("SKILL.md") + with pytest.raises(ap.AgentPluginError): + ap.Skill(instructions.parent) + + pending = ap.Plugin(root).skill("demo") + outside = tmp_path / "outside.md" + outside.write_text(source, encoding="utf-8") + instructions.unlink() + instructions.symlink_to(outside) + with pytest.raises(ap.ValidationError): + _source = pending.source + + def test_plugin_from_project_uses_exact_build_plan_inventory(tmp_path: Path) -> None: project, root = _project(tmp_path) outside = tmp_path / "outside" @@ -141,11 +243,12 @@ def test_plugin_from_project_uses_exact_build_plan_inventory(tmp_path: Path) -> (root / "README.md").write_text("# Unselected\n", encoding="utf-8") plugin = ap.Plugin.from_project(project) - plan = ap.build_plan(project) assert plugin.path == root.resolve() - assert tuple(path.relative_to(plugin.path) for path in plugin.files) == tuple( - Path(mapping.target.as_posix()) for mapping in plan.files + assert tuple(path.relative_to(plugin.path).as_posix() for path in plugin.files) == ( + "mcp.json", + "plugin.json", + "skills/demo/SKILL.md", ) assert plugin.manifest.name == "demo-plugin" assert plugin.skill("demo") is plugin.skills[0] @@ -154,7 +257,7 @@ def test_plugin_from_project_uses_exact_build_plan_inventory(tmp_path: Path) -> def test_plugin_from_project_uses_staged_sdist_inventory(tmp_path: Path) -> None: - project, configured = _project(tmp_path) + project, _root = _project(tmp_path) staged = project / ".agent-plugin" staged_skill = staged / "skills" / "staged" staged_skill.mkdir(parents=True) @@ -170,7 +273,6 @@ def test_plugin_from_project_uses_staged_sdist_inventory(tmp_path: Path) -> None assert plugin.path == staged.resolve() assert plugin.manifest.name == "staged-plugin" assert plugin.skill("staged").source == staged_source.decode() - assert configured.resolve() != plugin.path def test_plugin_skill_reports_sorted_available_names(tmp_path: Path) -> None: diff --git a/tests/test_skill.py b/tests/test_skill.py index 64c9c22..be20d27 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -16,7 +16,6 @@ def test_skill_exposes_its_directory_and_nested_files(tmp_path: Path) -> None: assert skill.path == root.resolve() assert Path(skill) == root.resolve() - assert os.fspath(skill) == str(root.resolve()) assert skill / "SKILL.md" == (root / "SKILL.md").resolve() assert ( skill / "references" / "guide¬es.md" @@ -55,26 +54,21 @@ def test_skill_tree_drives_text_and_notebook_display(tmp_path: Path) -> None: assert str(skill) == expected assert repr(skill) == expected assert skill._repr_html_() == f"
{escape(expected)}
" - assert skill.tree(max_depth=1) == "\n".join( + assert skill.tree(max_depth=1, max_files=1) == "\n".join( ( f"{root.resolve()}{os.sep}", - "|-- agents/", - "| `-- ...", - "|-- references/", - "| `-- ...", - "|-- scripts/", - "| `-- ...", - "`-- SKILL.md", + "`-- agents/", + " `-- ...", + "... 3 more files", ) ) - assert skill.tree(max_files=1).endswith("... 3 more files") def test_skill_lazily_splits_and_caches_its_source_text(tmp_path: Path) -> None: root = _skill_root(tmp_path) skill = ap.Skill(root) - assert "SKILL.md" in skill.tree() + skill.tree() _write_skill( root, b"---\r\nname: first\r\ndescription: First version\r\n---\r\n\r\n# First\r\n", @@ -148,7 +142,6 @@ def test_skill_reports_structural_document_errors( _frontmatter = ap.Skill(root).frontmatter assert captured.value.path == (root / "SKILL.md").resolve() - assert captured.value.issues def test_skill_caches_structural_errors(tmp_path: Path) -> None: diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 133364f..1c39865 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -1,17 +1,18 @@ from __future__ import annotations -import base64 -import csv -import hashlib import json import os +import shutil import stat +import subprocess +import sys import warnings import zipfile from pathlib import Path, PurePosixPath from typing import IO, Any import pytest +from wheel_assertions import assert_wheel_record import agent_plugins as ap @@ -29,7 +30,6 @@ def test_attach_wheel_reads_current_project_configuration( result = ap.attach_wheel(wheel) - assert isinstance(result, ap.WheelAttachment) assert result.files == ( PurePosixPath("plugin.json"), PurePosixPath("skills/demo/SKILL.md"), @@ -129,7 +129,7 @@ def test_in_place_attachment_returns_resolved_artifact_details(tmp_path: Path) - with zipfile.ZipFile(wheel) as archive: assert f"{DIST_INFO}/RECORD.jws" not in archive.namelist() assert f"{DIST_INFO}/RECORD.p7s" not in archive.namelist() - _assert_record(wheel) + assert_wheel_record(wheel) def test_output_directory_preserves_source_and_refuses_overwrite( @@ -219,6 +219,93 @@ def test_existing_marker_or_payload_reports_replacement( assert result.replaced_existing_plugin is True +@pytest.mark.parametrize("scheme", ["purelib", "platlib"]) +def test_attachment_installs_selected_payload_over_relocated_plugin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scheme: str +) -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for wheel installation") + plan = _plan(tmp_path, ("plugin.json",)) + plan.files[0].source.write_text( + json.dumps( + { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "selected-plugin", + } + ), + encoding="utf-8", + ) + prefix = f"demo-1.0.0.data/{scheme}" + wheel = _wheel( + tmp_path / WHEEL_NAME, + extra_members={ + f"{DIST_INFO}/WHEEL": ( + "Wheel-Version: 1.0\n" + f"Root-Is-Purelib: {str(scheme == 'purelib').lower()}\n" + "Tag: py3-none-any\n" + ).encode(), + f"{prefix}/{PLUGIN_ROOT}/plugin.json": b'{"name":"stale"}', + f"{prefix}/{PLUGIN_ROOT}/old.txt": b"stale resource", + f"{prefix}/{DIST_INFO}/agent_plugins.json": b"stale marker", + f"{prefix}/{DIST_INFO}/RECORD.jws": b"stale signature", + f"{prefix}/demo_resource.txt": b"library resource", + }, + ) + + result = ap.attach_wheel(wheel, plan=plan) + installed = tmp_path / "installed" + subprocess.run( + [ + uv, + "pip", + "install", + "--python", + sys.executable, + "--target", + str(installed), + "--no-deps", + str(wheel), + ], + env={**os.environ, "UV_CACHE_DIR": str(tmp_path / "uv-cache")}, + check=True, + timeout=60, + capture_output=True, + text=True, + ) + monkeypatch.syspath_prepend(str(installed)) + + plugin = ap.locate("demo") + assert plugin.manifest.name == "selected-plugin" + assert tuple(path.relative_to(plugin.path).as_posix() for path in plugin.files) == ( + "plugin.json", + ) + assert {path.name for path in plugin.path.iterdir()} == {"plugin.json"} + assert (installed / "demo_resource.txt").read_bytes() == b"library resource" + assert result.replaced_existing_plugin is True + assert result.removed_signatures == ( + PurePosixPath(f"{prefix}/{DIST_INFO}/RECORD.jws"), + ) + assert_wheel_record(wheel) + + +def test_attachment_rejects_relocated_distribution_metadata(tmp_path: Path) -> None: + wheel = _wheel( + tmp_path / WHEEL_NAME, + extra_members={ + f"demo-1.0.0.data/purelib/{DIST_INFO}/METADATA": ( + b"Metadata-Version: 2.4\nName: demo\nVersion: 9.0.0\n" + ), + }, + ) + original = wheel.read_bytes() + + with pytest.raises(ap.AgentPluginError, match="relocated distribution metadata"): + ap.attach_wheel(wheel, plan=_plan(tmp_path, ("plugin.json",))) + + assert wheel.read_bytes() == original + + def test_attachment_preserves_archive_metadata_modes_and_record(tmp_path: Path) -> None: plan = _plan(tmp_path, ("plugin.json", "bin/server.py")) server = plan.files[1].source @@ -247,22 +334,26 @@ def test_attachment_preserves_archive_metadata_modes_and_record(tmp_path: Path) stat.S_IFREG | server_mode ) assert result.removed_signatures == () - _assert_record(wheel) + assert_wheel_record(wheel) @pytest.mark.parametrize( - ("member", "message"), + "member", [ - ("/absolute.py", "unsafe archive member"), - ("C:drive-relative.py", "unsafe archive member"), - ("demo/../outside.py", "unsafe archive member"), - ("demo\\outside.py", "unsafe archive member"), + "/absolute.py", + "C:drive-relative.py", + "demo/../outside.py", + "demo\\outside.py", + "demo/name\x00suffix.py", ], ) -def test_attach_wheel_rejects_unsafe_members( - tmp_path: Path, member: str, message: str -) -> None: - wheel = _wheel(tmp_path / WHEEL_NAME, extra_members={member: b"unsafe"}) +def test_attach_wheel_rejects_unsafe_members(tmp_path: Path, member: str) -> None: + stored_member = member.replace("\x00", "?") + wheel = _wheel(tmp_path / WHEEL_NAME, extra_members={stored_member: b"unsafe"}) + if "\x00" in member: + wheel.write_bytes( + wheel.read_bytes().replace(stored_member.encode(), member.encode()) + ) if "\\" in member: contents = wheel.read_bytes() normalized = member.replace("\\", "/").encode() @@ -271,7 +362,7 @@ def test_attach_wheel_rejects_unsafe_members( wheel.write_bytes(contents.replace(normalized, member.encode())) original = wheel.read_bytes() - with pytest.raises(ap.AgentPluginError, match=message): + with pytest.raises(ap.AgentPluginError, match="unsafe archive member"): ap.attach_wheel(wheel, plan=_plan(tmp_path, ("plugin.json",))) assert wheel.read_bytes() == original @@ -360,6 +451,8 @@ def test_attach_wheel_reports_missing_corrupt_and_non_wheel_inputs( (("C:plugin.json",), "plugin root"), (("plugin.json", "plugin.json"), "duplicate target"), (("assets", "assets/icon.svg"), "file-directory target collisions"), + (("plugin.json", "assets/name\x00suffix.txt"), "plugin root"), + (("skills/demo/SKILL.md",), "must include plugin.json"), ], ) def test_attach_wheel_rejects_invalid_supplied_plan( @@ -375,6 +468,31 @@ def test_attach_wheel_rejects_invalid_supplied_plan( assert wheel.read_bytes() == original +@pytest.mark.parametrize("invalid_location", ["wheel", "output", "source"]) +def test_attach_wheel_reports_invalid_filesystem_names( + tmp_path: Path, invalid_location: str +) -> None: + wheel = _wheel(tmp_path / WHEEL_NAME) + original = wheel.read_bytes() + plan = _plan(tmp_path, ("plugin.json",)) + invalid = tmp_path / "invalid\0path.whl" + if invalid_location == "source": + plan = ap.BuildPlan( + project=plan.project, + root=plan.root, + files=(ap.FileMapping(invalid, PurePosixPath("plugin.json")),), + ) + + with pytest.raises(ap.AgentPluginError, match=r"cannot be read|cannot be used"): + ap.attach_wheel( + invalid if invalid_location == "wheel" else wheel, + plan=plan, + output_dir=invalid if invalid_location == "output" else None, + ) + + assert wheel.read_bytes() == original + + def test_symlink_loop_is_reported_as_an_artifact_error(tmp_path: Path) -> None: wheel = tmp_path / WHEEL_NAME try: @@ -589,21 +707,3 @@ def _metadata(info: zipfile.ZipInfo) -> tuple[object, ...]: info.internal_attr, info.external_attr, ) - - -def _assert_record(wheel: Path) -> None: - with zipfile.ZipFile(wheel) as archive: - record_name = f"{DIST_INFO}/RECORD" - rows = list(csv.reader(archive.read(record_name).decode().splitlines())) - members = {name for name in archive.namelist() if not name.endswith("/")} - row_names = [row[0] for row in rows] - assert len(row_names) == len(set(row_names)) - assert set(row_names) == members - for name, digest, size in rows: - if name == record_name: - assert (digest, size) == ("", "") - continue - value = archive.read(name) - encoded = base64.urlsafe_b64encode(hashlib.sha256(value).digest()) - assert digest == f"sha256={encoded.rstrip(b'=').decode()}" - assert size == str(len(value)) diff --git a/tests/wheel_assertions.py b/tests/wheel_assertions.py new file mode 100644 index 0000000..db53f04 --- /dev/null +++ b/tests/wheel_assertions.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import base64 +import csv +import hashlib +import zipfile +from pathlib import Path, PurePosixPath + + +def assert_wheel_record(wheel: Path) -> None: + with zipfile.ZipFile(wheel) as archive: + records = [ + name + for name in archive.namelist() + if name.endswith(".dist-info/RECORD") + and len(PurePosixPath(name).parts) == 2 + ] + assert len(records) == 1 + record_name = records[0] + rows = list(csv.reader(archive.read(record_name).decode().splitlines())) + files = {name for name in archive.namelist() if not name.endswith("/")} + row_names = [row[0] for row in rows] + assert len(row_names) == len(set(row_names)) + assert set(row_names) == files + for name, digest, size in rows: + if name == record_name: + assert (digest, size) == ("", "") + continue + value = archive.read(name) + encoded = base64.urlsafe_b64encode(hashlib.sha256(value).digest()) + assert digest == f"sha256={encoded.rstrip(b'=').decode()}" + assert size == str(len(value)) From 07463b2d1c488f1ae3dba4bf8f8932a0aaf321d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Mon, 7 Sep 2026 18:42:46 +0200 Subject: [PATCH 2/4] docs: demonstrate packaged skills through Python Keep the README focused on packaging and discovery, and make the quickstart build a library with its matching skill. Exercise the documented files through build, install, and Python execution, and verify every published documentation page. --- README.md | 140 ++++++-------------------- docs/guide/getting-started.md | 104 +++++++++---------- docs/guide/verify-package.md | 2 +- docs/guide/what-is-an-agent-plugin.md | 30 +++--- docs/index.md | 6 +- docs/integrations/agent-skills.md | 2 +- docs/scripts/verify-build.mjs | 73 ++++++-------- tests/test_docs.py | 92 +++++++++++++---- 8 files changed, 201 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index 8674215..c4f5a9a 100644 --- a/README.md +++ b/README.md @@ -23,138 +23,66 @@ Apache-2.0 license

-[Agent Plugins](https://agent-plugins.org/) gives reusable [Agent Skills](https://agentskills.io/specification) and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/specification) servers one package structure that compatible clients can discover consistently. A `plugin.json` manifest identifies the format, fixed locations expose its portable components, and namespaced [client extensions](https://peter-gy.github.io/agent-plugins/integrations/client-extensions) preserve client-specific behavior. Authors maintain one plugin layout, and each client loads the parts it supports. +`agent-plugins` ships a Python library and its agent integrations in one package. An agent with Python execution can read its packaged instructions, then use the library in the same environment. Library code, skills, tool configuration, and resources share one release. -The specification defines that directory boundary. `agent-plugins` carries the complete plugin through Python packaging beside the library it extends. Regular Python [wheels](https://packaging.python.org/en/latest/specifications/binary-distribution-format/) and [source distributions](https://packaging.python.org/en/latest/specifications/source-distribution-format/) can contain the manifest, skills, MCP configuration, and extension files. Installing the distribution makes its matching Agent Plugin available through Python metadata. Editable installs point discovery at the authored directory. +The [Agent Plugins format](https://agent-plugins.org/) defines the directory: a manifest, [Agent Skills](https://agentskills.io/specification) for instructions and resources, [Model Context Protocol (MCP)](https://modelcontextprotocol.io/specification) server configuration for tools, and client extensions. This library packages that directory and makes it discoverable through Python metadata. Agent clients choose which components to activate. -The library and plugin share one release boundary. Teams can update library behavior, skills, MCP configuration, and client extensions together, evaluate the resulting integration against that build, then version, publish, install, and roll them back as one unit. Users and agents install one package, and compatible clients can discover the plugin for that installed library version immediately. +## Package your plugin -Use a build-backend adapter when `agent-plugins` owns the Python build path. When another tool already produced the wheel, attach the configured plugin as a separate artifact step. The command and Python API rewrite the input after the complete attached artifact succeeds. Pass `--output-dir` or `output_dir` to preserve it. - -```console -agent-plugins attach-wheel dist/example-1.0.0-py3-none-any.whl --project . -``` - -```python -import agent_plugins as ap - -result = ap.attach_wheel("dist/example-1.0.0-py3-none-any.whl") -print(result.output) -``` - -Both paths use the same build plan and wheel writer. See [Attach a prebuilt wheel](https://peter-gy.github.io/agent-plugins/guide/attach-wheel) for output copies, result fields, reruns, and signature handling. - -## Quickstart - -Keep the plugin directory beside its Python package: - -```text -my-project/ -├── plugin.json -├── skills/ -│ └── use-my-project/ -│ └── SKILL.md -└── packages/ - └── python/ - ├── pyproject.toml - └── src/ - └── my_project/ - └── __init__.py -``` - -Create `plugin.json`: - -```json -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "my-project" -} -``` - -Create `skills/use-my-project/SKILL.md`: - -```md ---- -name: use-my-project -description: Use my-project to process project records. ---- - -# Use my-project - -Import `my_project` and call its public API. -``` - -Create an empty `packages/python/src/my_project/__init__.py`, then configure the Python project: - -Wrap the [uv build backend](https://docs.astral.sh/uv/concepts/build-backend/) in `packages/python/pyproject.toml`: +Keep `plugin.json` and `skills/` beside your code. For a project using the [uv build backend](https://docs.astral.sh/uv/concepts/build-backend/), configure `pyproject.toml`: ```toml -[project] -name = "my-project" -version = "0.1.0" -requires-python = ">=3.10" - [build-system] requires = ["agent-plugins", "uv_build"] build-backend = "agent_plugins.build.uv_build" [tool.agent-plugins] -root = "../.." +root = "." ``` -With the [uv package manager](https://docs.astral.sh/uv/) installed, preview the selected files, build the package, install the wheel in a temporary environment, and locate its Agent Plugin: +Build with the [uv package manager](https://docs.astral.sh/uv/): ```console -uv run --with agent-plugins agent-plugins plan packages/python -uv build packages/python --out-dir dist -uv run \ - --with agent-plugins \ - --with dist/my_project-0.1.0-py3-none-any.whl \ - agent-plugins locate my-project +uv build ``` -```text -/path/to/site-packages/my_project-0.1.0.agent-plugin -``` +The [quickstart](https://peter-gy.github.io/agent-plugins/guide/getting-started) creates a complete project, builds it, and locates the installed plugin. Use the [Hatchling adapter](https://peter-gy.github.io/agent-plugins/guide/build-backends#hatchling) for Hatchling projects, or [attach a prebuilt wheel](https://peter-gy.github.io/agent-plugins/guide/attach-wheel) when another tool owns the build: -The printed Agent Plugin directory and the importable library came from the same wheel and share its distribution version. +```console +agent-plugins attach-wheel dist/my_project-0.1.0-py3-none-any.whl --project . +``` -The [complete quickstart](https://peter-gy.github.io/agent-plugins/guide/getting-started) includes the Python package and Agent Skill files needed for a runnable project. +Attachment updates the wheel in place. Pass `--output-dir` to preserve the input. -## Inspect a project or installation +## Inspect an installed plugin -Add `agent-plugins` to runtime dependencies when Python code calls the inspection API: +Install `agent-plugins` in the environment you want to inspect. The package includes its own Agent Skill: -```toml -[project] -dependencies = ["agent-plugins"] +```console +pip install agent-plugins ``` ```python import agent_plugins as ap -source = ap.Plugin.from_project("packages/python") -installed = ap.locate("my-project") -skill = source.skill("use-my-project") +plugin = ap.locate("agent-plugins") +skill = plugin.skill("agent-plugins") -print(source.manifest.name) print(skill.source) print(skill.file("SKILL.md")) - -if installed.mcp is not None: - for name, server in installed.mcp.servers.items(): - print(name, server) ``` -`Plugin.from_project()` exposes exactly the files selected by `[tool.agent-plugins]`. After installing a build produced from that selection, `locate()` exposes the same plugin-relative inventory. `Plugin(path)` remains the directory-tree constructor for every current file below a plugin root. - -`skill.source` returns the complete cached `SKILL.md` text. `skill.file()` checks that a resource belongs to the selected inventory before returning its path. +Pass your library's distribution name to `locate()` to inspect its plugin. Use [`Plugin.from_project()`](https://peter-gy.github.io/agent-plugins/guide/inspect-project) to inspect the selected source files before building. -`locate()` accepts the Python distribution name used by `pip`. `installed.manifest.name` is a separate Agent Plugin identity. +## Documentation -Code-mode agents that can execute Python can use the installed distribution as their plugin source. Through the same API, they can inspect the manifest and MCP configuration, traverse `plugin.skills`, read skill instructions, and open client extension files through native `Path` operations. See [Inspect installed plugins](https://peter-gy.github.io/agent-plugins/guide/inspect-installed). +- [Get started](https://peter-gy.github.io/agent-plugins/guide/getting-started): package, install, and locate a plugin. +- [How packaging works](https://peter-gy.github.io/agent-plugins/guide/artifact-lifecycle): wheels, source distributions, and editable installs. +- [Integrate](https://peter-gy.github.io/agent-plugins/guide/inspect-installed): read skills, inspect files, and resolve MCP configuration. +- [Python API](https://peter-gy.github.io/agent-plugins/reference/python-api) · [CLI](https://peter-gy.github.io/agent-plugins/reference/cli) · [Configuration](https://peter-gy.github.io/agent-plugins/reference/pyproject) -## Core model +
+Packaging lifecycle

@@ -164,22 +92,12 @@ Code-mode agents that can execute Python can use the installed distribution as t

-The build plan selects the plugin files and checks their paths before a build-backend adapter or `attach_wheel()` packages them beside the library. Manifest, MCP, and skill-document content is read on first access through the inspection API and cached for that handle. - -## Related work - -[TanStack Intent](https://tanstack.com/intent/) versions Agent Skills with npm library releases and lets agents discover them from installed dependencies. `agent-plugins` applies that package-manager principle to Python and carries the broader Agent Plugins format: the manifest, optional skills and MCP configuration, and client extension files. +
## Development -[`development_docs/`](https://github.com/peter-gy/agent-plugins/tree/main/development_docs) covers contributor setup, architecture, testing, packaging, documentation, and releases. Serve the docs through [Portless](https://portless.sh/): - -```console -pnpm --dir docs dev -``` - -The main checkout uses `https://docs.agent-plugins.localhost`. Linked worktrees receive a branch-prefixed subdomain. +See [development_docs/](https://github.com/peter-gy/agent-plugins/tree/main/development_docs) for setup, architecture, checks, and releases. Serve the documentation locally with `pnpm --dir docs dev`. ## License -Licensed under the [Apache License 2.0](https://github.com/peter-gy/agent-plugins/blob/main/LICENSE). +[Apache-2.0](https://github.com/peter-gy/agent-plugins/blob/main/LICENSE). diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index accf8a8..45be6b8 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,16 +1,16 @@ --- title: Get started -description: Package an Agent Skill in a wheel and locate the installed Agent Plugin. +description: Build a Python library with an Agent Skill, install the wheel, and read its instructions from Python. --- -# Package and locate your first Agent Plugin +# Package and use your first Agent Plugin -Create a Python distribution that carries an Agent Plugin beside the library it extends. This minimal plugin contains one Agent Skill. The completed flow builds one wheel, installs it, and locates the plugin from that installed distribution. +Build a Python library with instructions for using it. Install the resulting wheel, then read the packaged skill and call the library from the same Python environment. ## Prerequisites - Python 3.10 through 3.14. -- The [uv package manager](https://docs.astral.sh/uv/) with its `uv build` command. +- The [uv package manager](https://docs.astral.sh/uv/) for building and creating temporary environments. The commands download their declared dependencies. ## Create the project @@ -32,29 +32,37 @@ my-project/ Create `plugin.json` at the plugin root: -```json +```json [plugin.json] { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "my-project" } ``` -Create `skills/use-my-project/SKILL.md`: +Write `skills/use-my-project/SKILL.md`. An [Agent Skill](https://agentskills.io/specification) provides instructions, metadata, and optional supporting resources: -```md +```md [skills/use-my-project/SKILL.md] --- name: use-my-project -description: Use my-project to process project records. +description: Use my-project to greet a person by name. --- # Use my-project -Import `my_project` and call its public API. +Import `greet` from `my_project` and call it with the person's name. +The function returns a greeting string. ``` -Create an empty `packages/python/src/my_project/__init__.py`, then configure `packages/python/pyproject.toml`: +Create the library in `packages/python/src/my_project/__init__.py`: -```toml +```python [packages/python/src/my_project/__init__.py] +def greet(name: str) -> str: + return f"Hello, {name}!" +``` + +Configure `packages/python/pyproject.toml`. The [uv build backend](https://docs.astral.sh/uv/concepts/build-backend/) builds the Python package, and the `agent-plugins` adapter adds the selected plugin files: + +```toml [packages/python/pyproject.toml] [project] name = "my-project" version = "0.1.0" @@ -68,78 +76,60 @@ build-backend = "agent_plugins.build.uv_build" root = "../.." ``` -`root` starts at the directory containing `pyproject.toml`. The value `../..` resolves to `my-project/`, where `plugin.json` lives. +`root` resolves from the directory containing `pyproject.toml`. Here it reaches `my-project/`, where `plugin.json` lives. -## Inspect the build plan +## Build and install -Run the inspection command in a temporary uv environment and print the Agent Plugin build plan: +Run from `my-project/`: ```console uv run --with agent-plugins agent-plugins plan packages/python +uv build packages/python --out-dir dist ``` -The output begins with the resolved authored plugin root, followed by one target and source path per selected file: +`plan` lists the manifest and skill selected for packaging. It checks paths and containment. Document validation happens when the Python API reads content. -```text -root /path/to/my-project -plugin.json /path/to/my-project/plugin.json -skills/use-my-project/SKILL.md /path/to/my-project/skills/use-my-project/SKILL.md -``` +The build creates a [wheel](https://packaging.python.org/en/latest/specifications/binary-distribution-format/), an installable archive, and a [source distribution](https://packaging.python.org/en/latest/specifications/source-distribution-format/) from which another wheel can be built. -The command separates columns with tabs and prints absolute local paths. Add `--json` when another program consumes the plan. +Open Python in a temporary environment containing the wheel and the inspection library: -::: info Selection before validation -`plan` checks project configuration, selected paths, and containment. It does not parse `plugin.json` or validate the Agent Skills frontmatter. -::: +```console +uv run \ + --with agent-plugins \ + --with dist/my_project-0.1.0-py3-none-any.whl \ + python +``` -## Build and install +## Read the skill and use the library -Build the wheel and source distribution from the repository root: +Run in that Python session: -```console -uv build packages/python --out-dir dist -``` +```python +import agent_plugins as ap +from my_project import greet -Install the wheel in a temporary environment with `agent-plugins`, then locate its Agent Plugin: +plugin = ap.locate("my-project") +skill = plugin.skill("use-my-project") -```console -uv run \ - --with agent-plugins \ - --with dist/my_project-0.1.0-py3-none-any.whl \ - agent-plugins locate my-project +print(skill.source) +print(greet("Ada")) ``` -The command prints an absolute plugin root inside uv's temporary environment, similar to: +The first print shows the packaged instructions. The final line is: ```text -/path/to/site-packages/my_project-0.1.0.agent-plugin +Hello, Ada! ``` -The `.agent-plugin` directory and the importable `my_project` package share the wheel's distribution version. A compatible client can discover the plugin immediately from the installed metadata. +The importable library and the skill came from the same wheel. An agent with Python execution can read the instructions through this API, then use the library for its task. The host agent controls which instructions to load and which code to run. -## Inspect from application code +## Apply this to your library -Add `agent-plugins` to the runtime dependencies of a Python project that needs to inspect its own or another installed distribution: +Keep `agent-plugins` in `[build-system].requires` for packaging. Add it to runtime dependencies when your installed Python code calls the inspection API: ```toml [project] dependencies = ["agent-plugins"] ``` -Then inspect the installation from Python: - -```python -import agent_plugins as ap - -plugin = ap.locate("my-project") - -print(plugin.manifest.name) -print(plugin.skills[0] / "SKILL.md") -``` - -```text -my-project -/path/to/site-packages/my_project-0.1.0.agent-plugin/skills/use-my-project/SKILL.md -``` - -`plugin.manifest.name` reads and validates `plugin.json` on first access. Use [Inspect an authored project](/guide/inspect-project) to open the exact build selection before installation. Learn how the three artifact modes differ in [How packaging works](/guide/artifact-lifecycle). +Use the [Hatchling adapter](/guide/build-backends#hatchling) for Hatchling builds or [attach a prebuilt wheel](/guide/attach-wheel) when another tool produces the artifact. Use [project inspection](/guide/inspect-project) before building and [package verification](/guide/verify-package) before publishing. diff --git a/docs/guide/verify-package.md b/docs/guide/verify-package.md index bb03362..06b28e1 100644 --- a/docs/guide/verify-package.md +++ b/docs/guide/verify-package.md @@ -17,7 +17,7 @@ Check the authored plugin root and every target path. The plan should contain `p ## 2. Validate authored documents -Construct a direct `Plugin` handle and access the fields your consumers use: +Open the project's selected plugin files and access the fields your consumers use: ```python import agent_plugins as ap diff --git a/docs/guide/what-is-an-agent-plugin.md b/docs/guide/what-is-an-agent-plugin.md index e1897a5..dcf45c4 100644 --- a/docs/guide/what-is-an-agent-plugin.md +++ b/docs/guide/what-is-an-agent-plugin.md @@ -5,19 +5,26 @@ description: Ship a Python library and its Agent Plugin in one versioned distrib # What is an Agent Plugin? -An **agent client** is an application that loads agent instructions and integrations. [Agent Skills](https://agentskills.io/specification) provide reusable instructions and resources. [Model Context Protocol (MCP)](https://modelcontextprotocol.io/specification) servers connect agents to tools and services. Both can be reused across clients, yet each client's native plugin format may place and describe them differently. +An [Agent Plugin](https://agent-plugins.org/) is a directory with a `plugin.json` manifest and optional components. [Agent Skills](https://agentskills.io/specification) provide instructions and resources. [Model Context Protocol (MCP)](https://modelcontextprotocol.io/specification) server declarations describe connections to tools and services. Namespaced [client extensions](/integrations/client-extensions) hold client-specific data and files. -[Agent Plugins](https://agent-plugins.org/) defines one small package contract: a `plugin.json` manifest, fixed locations for skills and MCP server configuration, and namespaced [client extensions](/integrations/client-extensions). Plugin authors maintain one structure. Compatible clients discover the same structure and load the parts they support. +`agent-plugins` packages that directory with a Python library. An agent that can execute Python can read the library's packaged instructions, then compose calls to its API for a task. The same workflow applies to a short script, an interactive session, or a notebook. -The specification leaves distribution and installation to clients. `agent-plugins` carries that directory through Python packaging beside the library it extends. A regular wheel installs the code and plugin together. An editable wheel points discovery at the authored plugin directory. The Python API and command-line interface (CLI) locate either form through the installed distribution metadata. +With `agent-plugins` [installed](/guide/inspect-installed), read its own packaged skill: -A [wheel](https://packaging.python.org/en/latest/specifications/binary-distribution-format/) is an installable Python archive. A [source distribution](https://packaging.python.org/en/latest/specifications/source-distribution-format/) carries source files for a build frontend to turn into a wheel. +```python +import agent_plugins as ap + +plugin = ap.locate("agent-plugins") +print(plugin.skill("agent-plugins").source) +``` + +An **agent client** is the application hosting the model and its execution tools. It decides which instructions to load and which components to activate. The Python API supplies files, text, validated configuration, and subprocess inputs for that integration. ## One release boundary The built distribution captures the library code and selected Agent Plugin files from the same source revision. Run library tests and evaluate the plugin against that build, then publish one distribution version. Installing another version replaces both packaged surfaces together. -For users and agents, install the Python distribution once. The Agent Plugin is immediately available for compatible clients to discover. A code-mode agent can call `agent_plugins.locate()` and inspect the same packaged manifest, skills, MCP configuration, and client extension files through native Python paths. +A [wheel](https://packaging.python.org/en/latest/specifications/binary-distribution-format/) is the installable archive carrying both code and plugin. A [source distribution](https://packaging.python.org/en/latest/specifications/source-distribution-format/) carries the source for rebuilding that wheel. An editable installation points discovery at the authored plugin directory. ## The lifecycle @@ -25,12 +32,9 @@ For users and agents, install the Python distribution once. The Agent Plugin is ```mermaid flowchart TD - author[Python project
Library code and Agent Plugin directory] --> plan[Build plan] - plan --> modes[Regular wheel
Source distribution
Editable wheel] - modes --> install[Installed library, Agent Plugin, and marker] - install --> discovery[Marker-based discovery] - discovery --> handle[Plugin handle and file inventory] - handle --> documents[Lazy manifest, skill, and MCP access] + author[Author library code
and plugin instructions] --> build[Build one distribution] + build --> install[Install library and plugin together] + install --> use[Read the skill from Python
and call the library] ``` @@ -39,8 +43,6 @@ The **authored plugin directory** is the directory you maintain. Its root contai The **build plan** is an ordered set of source-to-target file mappings. `agent-plugins plan` shows this selection before a build. -The **`agent_plugins.json` marker** lives inside the Python distribution metadata. It records the installed plugin root and exact file inventory. - A **Plugin handle** is the filesystem-backed Python object returned by `agent_plugins.locate()`, selected from a project with `Plugin.from_project()`, or created from a complete directory tree with `Plugin(path)`. ## Plugin contents @@ -58,8 +60,6 @@ A plugin directory has one required file and three optional content surfaces. ## Identities and versions -Several names and versions coexist by design. - diff --git a/docs/index.md b/docs/index.md index a38a26d..d2bf1ef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Ship Agent Plugins with Python packages as one synchronized release hero: text: Ship Agent Plugins with Python packages. - tagline: Package portable skills and MCP server configuration, plus client extensions, with their Python library. One install exposes the matching plugin through Python metadata. + tagline: Release your library, agent instructions, and tool configuration together. One Python install makes the matching plugin available to agent clients. image: light: /brand/agent-plugins-lockup-vertical-light.svg dark: /brand/agent-plugins-lockup-vertical-dark.svg @@ -15,8 +15,8 @@ hero: text: Get started link: /guide/getting-started - theme: alt - text: Learn the model - link: /guide/what-is-an-agent-plugin + text: Inspect a plugin + link: /guide/inspect-installed - theme: alt text: API reference link: /reference/python-api diff --git a/docs/integrations/agent-skills.md b/docs/integrations/agent-skills.md index 3931651..f93161c 100644 --- a/docs/integrations/agent-skills.md +++ b/docs/integrations/agent-skills.md @@ -57,7 +57,7 @@ print(skill.file("references/fields.md")) `skill.file(relative_path)` requires an exact selected file and rechecks containment. Use it for instructions, references, scripts, agents, and assets that came from an Agent Plugin inventory. -The `/` operator remains an ordinary unchecked `pathlib.Path` join for compatibility. +The `/` operator performs ordinary unchecked `pathlib.Path` joining. ## Content cache diff --git a/docs/scripts/verify-build.mjs b/docs/scripts/verify-build.mjs index 5215784..be56edf 100644 --- a/docs/scripts/verify-build.mjs +++ b/docs/scripts/verify-build.mjs @@ -1,4 +1,4 @@ -import { readFile, stat } from 'node:fs/promises' +import { glob, readFile, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -29,14 +29,21 @@ check(await isFile(indexPath), 'Missing built home page') check(await isFile(join(outputRoot, 'favicon.svg')), 'Missing built favicon') check(await isFile(join(outputRoot, 'og.png')), 'Missing built Open Graph image') check(await isFile(join(outputRoot, 'robots.txt')), 'Missing built robots file') -check( - await isFile(join(outputRoot, 'guide', 'attach-wheel.html')), - 'Missing built attach-wheel guide' -) -check( - await isFile(join(outputRoot, 'guide', 'inspect-project.html')), - 'Missing built inspect-project guide' -) +const sources = [] +for await (const source of glob('**/*.md', { + cwd: packageRoot, + exclude: ['node_modules/**', '.vitepress/**'] +})) { + sources.push(source.replaceAll('\\', '/')) +} +sources.sort() +for (const source of sources) { + check( + await isFile(join(outputRoot, source.replace(/\.md$/, '.html'))), + `Missing built page for ${source}` + ) + check(await isFile(join(outputRoot, source)), `Missing Markdown page for ${source}`) +} check(await isFile(join(outputRoot, 'llms.txt')), 'Missing generated llms.txt') check( await isFile(join(outputRoot, 'llms-full.txt')), @@ -97,41 +104,23 @@ const llmsPath = join(outputRoot, 'llms.txt') const llmsFullPath = join(outputRoot, 'llms-full.txt') if (await isFile(llmsPath)) { const llms = await readFile(llmsPath, 'utf8') - check( - llms.includes(`${deployedSiteUrl.href}guide/getting-started.md`), - 'llms.txt is missing the getting-started Markdown URL' - ) - check( - llms.includes(`${deployedSiteUrl.href}reference/python-api.md`), - 'llms.txt is missing the Python API Markdown URL' - ) - check( - llms.includes(`${deployedSiteUrl.href}guide/attach-wheel.md`), - 'llms.txt is missing the attach-wheel Markdown URL' - ) - check( - llms.includes(`${deployedSiteUrl.href}guide/inspect-project.md`), - 'llms.txt is missing the inspect-project Markdown URL' - ) + for (const source of sources.filter((source) => source !== 'index.md')) { + check( + llms.includes(new URL(source, deployedSiteUrl).href), + `llms.txt is missing the Markdown URL for ${source}` + ) + } } if (await isFile(llmsFullPath)) { const llmsFull = await readFile(llmsFullPath, 'utf8') - check( - llmsFull.includes(`url: ${deployedSiteUrl.href}`), - 'llms-full.txt is missing canonical page URLs' - ) - check( - llmsFull.includes('# Python API reference'), - 'llms-full.txt is missing the Python API content' - ) - check( - llmsFull.includes('# Attach a prebuilt wheel'), - 'llms-full.txt is missing the attach-wheel guide content' - ) - check( - llmsFull.includes('# Inspect an authored project'), - 'llms-full.txt is missing the inspect-project guide content' - ) + for (const source of sources) { + const url = new URL(source, deployedSiteUrl).href + check(llmsFull.includes(`url: ${url}`), `llms-full.txt is missing ${url}`) + if (await isFile(join(outputRoot, source))) { + const markdown = await readFile(join(outputRoot, source), 'utf8') + check(markdown.includes(`url: ${url}`), `Incorrect canonical URL in ${source}`) + } + } } if (failures.length > 0) { @@ -142,5 +131,5 @@ if (failures.length > 0) { ) process.exitCode = 1 } else { - console.log(`Verified documentation build at base path ${basePath || '/'}.`) + console.log(`Verified ${sources.length} documentation pages at base path ${basePath || '/'}.`) } diff --git a/tests/test_docs.py b/tests/test_docs.py index 259b3df..f69be26 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,28 +1,84 @@ from __future__ import annotations +import os import re +import shutil +import subprocess +import sys from pathlib import Path +import pytest + ROOT = Path(__file__).parent.parent -RELEASE_SPECIFIC_PIN = re.compile( - r"\b(?:agent-plugins|uv[-_]build|hatchling)==\d+(?:\.\d+){2}\b" -) -def test_documented_dependency_examples_are_release_independent() -> None: - documents = [ - ROOT / "README.md", - ROOT / "skills" / "agent-plugins" / "SKILL.md", - *(ROOT / "docs").rglob("*.md"), - *(ROOT / "development_docs").rglob("*.md"), - ] +def test_quickstart_installs_library_and_skill_from_documented_files( + tmp_path: Path, +) -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for wheel installation") + guide = (ROOT / "docs/guide/getting-started.md").read_text(encoding="utf-8") + for name, content in re.findall( + r"^```\w+ \[([^\]]+)\]\n(.*?)^```$", guide, re.MULTILINE | re.DOTALL + ): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") - pinned: dict[str, list[str]] = {} - for document in documents: - matches = sorted( - set(RELEASE_SPECIFIC_PIN.findall(document.read_text(encoding="utf-8"))) - ) - if matches: - pinned[document.relative_to(ROOT).as_posix()] = matches + project = tmp_path / "packages/python" + dist = tmp_path / "dist" + environment = {**os.environ, "UV_CACHE_DIR": str(tmp_path / "uv-cache")} + subprocess.run( + [ + uv, + "build", + "--wheel", + "--no-build-isolation", + "--python", + sys.executable, + "--out-dir", + str(dist), + str(project), + ], + env=environment, + check=True, + timeout=60, + capture_output=True, + text=True, + ) + wheel = dist / "my_project-0.1.0-py3-none-any.whl" + installed = tmp_path / "installed" + subprocess.run( + [ + uv, + "pip", + "install", + "--python", + sys.executable, + "--target", + str(installed), + "--no-deps", + str(wheel), + ], + env=environment, + check=True, + timeout=60, + capture_output=True, + text=True, + ) + example = re.findall(r"^```python\n(.*?)^```$", guide, re.MULTILINE | re.DOTALL)[0] + completed = subprocess.run( + [sys.executable, "-c", example], + cwd=tmp_path, + env={**os.environ, "PYTHONPATH": str(installed)}, + check=True, + timeout=60, + capture_output=True, + text=True, + ) - assert pinned == {} + instructions = (tmp_path / "skills/use-my-project/SKILL.md").read_text( + encoding="utf-8" + ) + assert completed.stdout == f"{instructions}\nHello, Ada!\n" From a1af64a4ffc096122f6f0df8de93fb4da6184b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Mon, 7 Sep 2026 18:42:52 +0200 Subject: [PATCH 3/4] ci: publish artifacts from shared validation Run the shared test and quality workflow before publishing its verified distributions. Include macOS in the Python matrix and document the release dependency chain. --- .github/workflows/ci.yml | 13 +++++++++++++ .github/workflows/publish.yml | 25 +++++++------------------ development_docs/testing-and-release.md | 17 ++++++++++++----- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 984126e..47a9243 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: pull_request: push: branches: [main] @@ -25,6 +26,8 @@ jobs: include: - os: windows-latest python-version: "3.12" + - os: macos-latest + python-version: "3.12" steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -78,3 +81,13 @@ jobs: - name: Build and verify distributions run: ./scripts/build-dist.sh + + - name: Upload verified distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: | + dist/agent_plugins-*.whl + dist/agent_plugins-*.tar.gz + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8a15668..7abca23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ concurrency: cancel-in-progress: false jobs: - build: + preflight: runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -29,26 +29,15 @@ jobs: enable-cache: true cache-dependency-glob: uv.lock - - name: Install locked dependencies - run: uv sync --locked + - name: Check release tag + run: ./scripts/check-release.sh - - name: Build release - run: | - ./scripts/check-release.sh - ./scripts/build-dist.sh - - - name: Upload package artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: dist - path: | - dist/agent_plugins-*.whl - dist/agent_plugins-*.tar.gz - retention-days: 1 - if-no-files-found: error + checks: + needs: preflight + uses: ./.github/workflows/ci.yml publish: - needs: build + needs: checks runs-on: ubuntu-latest timeout-minutes: 10 environment: diff --git a/development_docs/testing-and-release.md b/development_docs/testing-and-release.md index 1277759..1d82621 100644 --- a/development_docs/testing-and-release.md +++ b/development_docs/testing-and-release.md @@ -56,11 +56,13 @@ The distribution verifier: | Manifest normalization, immutability, issues, caching | `tests/test_manifest.py` | | MCP transports, security checks, partial validation, caching | `tests/test_mcp.py` | | Pure stdio launch resolution and subprocess inputs | `tests/test_mcp_resolution.py` | -| Release-independent dependency examples | `tests/test_docs.py` | +| Documented quickstart builds and installs library code with its skill | `tests/test_docs.py` | -CI runs pytest on Linux for Python 3.10 through 3.14 and on Windows for Python 3.12. The quality job runs formatting, lint, both type checkers, ShellCheck, and distribution verification. +CI runs pytest on Linux for Python 3.10 through 3.14 and on Windows and macOS for Python 3.12. The quality job runs formatting, lint, both type checkers, actionlint, ShellCheck, and distribution verification, then uploads the verified wheel and source distribution as the `dist` artifact. -The documentation workflow installs `docs/pnpm-lock.yaml`, runs the TypeScript check and VitePress build for pull requests, and deploys the built site from `main`. Configure the repository's Pages source as GitHub Actions before the first deployment. +`ci.yml` also exposes a [reusable workflow](https://docs.github.com/en/actions/concepts/workflows-and-actions/reusing-workflow-configurations), allowing publishing to run the same checks on the tagged source. The publish job downloads the artifact from that run after every Python matrix job and the quality job succeed. + +The documentation workflow installs `docs/pnpm-lock.yaml`, runs the TypeScript check and VitePress build for pull requests, and deploys the built site from `main`. Build verification enumerates the Markdown sources and checks their HTML output, downloadable Markdown, canonical URLs, and coverage in the generated text indexes. Configure the repository's Pages source as GitHub Actions before the first deployment. For a deployment, `actions/configure-pages` supplies the repository or custom-domain base path and complete site URL. The workflow passes those values as `BASE_PATH` and `SITE_URL`. VitePress uses `BASE_PATH` for assets and navigation, while canonical, sitemap, and social metadata use `SITE_URL`. Local development omits both variables, serves from `/`, and keeps the published site URL as the metadata fallback. @@ -82,9 +84,14 @@ git pull --ff-only origin main The dry run is a networked release preflight. It requires GitHub authentication, fetches `main` and tags, verifies a clean synchronized `main`, checks the final version and absent tag, verifies the exact commit's successful push CI run, resolves the repository URL, and stops before creating the tag. -The release run creates and pushes an annotated version tag. The publish workflow builds and verifies the artifacts, publishes through the PyPI trusted publisher, verifies the public package, and creates a GitHub release. +The release run creates and pushes an annotated version tag. Publishing follows this dependency order: + +```text +tag preflight → shared CI checks and artifact build → PyPI publish + → public installation verification → GitHub release +``` -Use the release script for tag creation. The publish workflow checks that the tagged commit belongs to `origin/main`, while the script supplies the stricter exact-commit CI gate. A manually pushed tag can bypass that stricter preflight. +Use the release script for tag creation. It checks the existing successful `main` CI run before tagging. The publish workflow verifies the annotated tag, package version, and membership in `origin/main`, then independently runs the shared CI checks before publishing the resulting artifacts. PyPI trusted publishing is configured against `.github/workflows/publish.yml` and the repository `pypi` environment. From 49a90533388aac7201942b8d95e19cd9d66df0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Mon, 7 Sep 2026 18:45:21 +0200 Subject: [PATCH 4/4] test: respect native Windows paths and line endings Preserve raw skill bytes in fixtures and retain the platform-specific traversal result when a path crosses a directory symlink. --- tests/test_docs.py | 2 +- tests/test_mcp_resolution.py | 6 +++++- tests/test_plugin.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_docs.py b/tests/test_docs.py index f69be26..1d0615c 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -24,7 +24,7 @@ def test_quickstart_installs_library_and_skill_from_documented_files( ): path = tmp_path / name path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") + path.write_bytes(content.encode("utf-8")) project = tmp_path / "packages/python" dist = tmp_path / "dist" diff --git a/tests/test_mcp_resolution.py b/tests/test_mcp_resolution.py index 627430f..da85109 100644 --- a/tests/test_mcp_resolution.py +++ b/tests/test_mcp_resolution.py @@ -403,13 +403,17 @@ def test_plugin_relative_command_keeps_filesystem_traversal_semantics( (root / "alias").symlink_to(nested, target_is_directory=True) except OSError as error: pytest.skip(f"symlinks unavailable: {error}") + # Windows normalizes parent segments before resolving directory symlinks. + if os.name == "nt": + (root / "server").write_text("root\n", encoding="utf-8") (root / "nested" / "server").write_text("nested\n", encoding="utf-8") data_dir = tmp_path / "data" data_dir.mkdir() launch = _mcp(root).resolve_stdio("local", data_dir=data_dir) - assert launch.command == str((root / "nested" / "server").resolve()) + expected = root / "server" if os.name == "nt" else root / "nested" / "server" + assert launch.command == str(expected.resolve()) @pytest.mark.parametrize("selected", ["alias", "server"]) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b11801d..9aaad43 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -209,7 +209,7 @@ def test_plugin_documents_read_contained_symlinks_lazily( def test_plugin_skill_document_uses_plugin_containment(tmp_path: Path) -> None: _project_path, root = _project(tmp_path) instructions = root / "skills" / "demo" / "SKILL.md" - source = instructions.read_text(encoding="utf-8") + source = instructions.read_bytes().decode("utf-8") shared = root / "shared" shared.mkdir() target = shared / "instructions.md" @@ -228,7 +228,7 @@ def test_plugin_skill_document_uses_plugin_containment(tmp_path: Path) -> None: pending = ap.Plugin(root).skill("demo") outside = tmp_path / "outside.md" - outside.write_text(source, encoding="utf-8") + outside.write_bytes(source.encode("utf-8")) instructions.unlink() instructions.symlink_to(outside) with pytest.raises(ap.ValidationError):
TermSourceUsed by