diff --git a/.gitignore b/.gitignore index c9c91c4..6973fec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ dist/ tmp/ *.log +__pycache__/ diff --git a/README.md b/README.md index ff39812..03ca2f0 100644 --- a/README.md +++ b/README.md @@ -127,3 +127,9 @@ If authentication fails during plugin install or first use, retry the plugin aut If a command inside a sprite cannot reach the network, ask Codex to inspect the sprite network policy before changing it. If a web service is not reachable, ask Codex to inspect the sprite's services, logs, and configured HTTP port. + +## Packaging a Directory Update + +Run `python3 scripts/package_plugin.py` to build and validate the upload ZIP. +See [directory release packaging](release/README.md) for the published-version +baseline, upload-specific settings, and remaining portal checks. diff --git a/plugins/sprites/.codex-plugin/plugin.json b/plugins/sprites/.codex-plugin/plugin.json index 1021ac3..58baf14 100644 --- a/plugins/sprites/.codex-plugin/plugin.json +++ b/plugins/sprites/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sprites", - "version": "0.2.0", + "version": "1.0.1", "description": "Use Sprites from Codex to create, inspect, and operate remote isolated development environments.", "author": { "name": "Fly.io", @@ -18,7 +18,7 @@ "mcpServers": "./.mcp.json", "interface": { "displayName": "Sprites", - "shortDescription": "Manage remote development sprites from Codex.", + "shortDescription": "Give Codex its own computer", "longDescription": "Create, list, inspect, and operate remote Sprites environments from Codex. Select individual Sprites in supported desktop composers and open a read-only Sprite Inspector for services, checkpoints, and recent logs.", "developerName": "Fly.io", "category": "Developer Tools", @@ -34,8 +34,9 @@ "Use Sprites to create a new remote environment for this repo and run the tests.", "Use Sprites to inspect services and logs for a selected sprite." ], - "brandColor": "#8AE234", - "composerIcon": "./assets/sprites-logo.svg", - "logo": "./assets/sprites-logo.svg" + "brandColor": "#16A34A", + "composerIcon": "./assets/fly-logo.png", + "logo": "./assets/fly-logo.png", + "supportURL": "https://docs.fly.io/about/support" } } diff --git a/plugins/sprites/assets/fly-logo.png b/plugins/sprites/assets/fly-logo.png new file mode 100644 index 0000000..69729ff Binary files /dev/null and b/plugins/sprites/assets/fly-logo.png differ diff --git a/release/README.md b/release/README.md new file mode 100644 index 0000000..e68dcda --- /dev/null +++ b/release/README.md @@ -0,0 +1,50 @@ +# Directory release packaging + +The last confirmed published directory version is **1.0.0**. The **1.0.1** +package is prepared and pending publication; preparing or uploading it does not +mean it has been approved or published. A previous upload labeled 0.2.0 entered +review before the published version was checked. + +Build the directory ZIP from the repository root with Python 3.12 or newer: + +```sh +python3 scripts/package_plugin.py +``` + +The output is `dist/sprites-1.0.1.zip` for the current version. ZIP files and +staging copies stay in ignored `dist/`; commit the source and release settings. +No additional Python packages or network access are needed to build the ZIP. + +The local manifest is the source for the version and listing. Before each +release, confirm the latest published version in the directory and update +`published_version` in [directory.json](directory.json). Increase the source +manifest version above it, and update `release_notes` for the intended release. +An explicit `--published-version 1.0.1` can override the recorded baseline for a +single build. This check cannot discover remote publication changes itself. + +The packager preserves the local plugin name `sprites` and local attribution +headers. In the upload copy only, it uses the verified directory identity +`app-6a8485b5beac8191954e37241acffe6d` in both manifests and the archive folder, +generates portable `plugin.json` and `mcp.json`, and removes `headers` and +`http_headers` from both MCP configurations. The directory rejected those +headers during existing-connection setup. Authentication belongs in the portal's +connection settings; no credentials belong in the package. + +Local checks cover version progression, matching manifests, the directory ID, +MCP endpoint and headers, subtitle length, default prompts, required HTTPS links, +brand color contrast against white, icon paths and PNG dimensions, skill presence, +and the final ZIP contents. The build uses fixed ZIP metadata so identical source +produces identical bytes. These checks cover known upload constraints, not every +portal rule. They do not verify live URLs, remote tool behavior, connection state, +review materials, scans, attestations, or publication status. + +After uploading to the existing entry, inspect its saved listing, connection, +review materials, and publication settings. Review and publication are separate +steps. Replacing an in-review submission requires cancelling review first; do +not infer approval to cancel from a request to prepare a ZIP. + +The logo is Fly.io's official color brandmark from +[Fly.io's brand assets](https://docs.fly.io/about/brand). The committed 512 x 512 +PNG was rendered from the official SVG with transparent square padding. It is +used for both listing and composer icons. The green brand color `#16A34A` has +approximately 3.30:1 contrast against white, exceeding the directory's 2:1 minimum. diff --git a/release/directory.json b/release/directory.json new file mode 100644 index 0000000..41a0ad2 --- /dev/null +++ b/release/directory.json @@ -0,0 +1,5 @@ +{ + "plugin_id": "app-6a8485b5beac8191954e37241acffe6d", + "published_version": "1.0.0", + "release_notes": "Adds guidance for selecting individual Sprites in supported desktop composers and opening the Sprite Inspector to inspect services, checkpoints, and recent logs. Verifies selected Sprite identities before remote operations." +} diff --git a/scripts/check_repository.py b/scripts/check_repository.py index acf9697..92915b6 100644 --- a/scripts/check_repository.py +++ b/scripts/check_repository.py @@ -22,6 +22,7 @@ ".ruff_cache", ".venv", "__pycache__", + "dist", } TEXT_SUFFIXES = { ".json", diff --git a/scripts/package_plugin.py b/scripts/package_plugin.py new file mode 100644 index 0000000..996c8b0 --- /dev/null +++ b/scripts/package_plugin.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Build and preflight the directory upload without changing local plugin configuration.""" + +from __future__ import annotations + +import argparse +import copy +import json +import re +import struct +import zipfile +from pathlib import Path, PurePosixPath +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parents[1] + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def stable_version(value: str) -> tuple[int, ...]: + require( + bool(re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", value)), + f"Expected a stable major.minor.patch version, got {value!r}", + ) + return tuple(map(int, value.split("."))) + + +def contrast_against_white(color: str) -> float: + require(bool(re.fullmatch(r"#[0-9a-fA-F]{6}", color)), "brandColor must be #RRGGBB") + channels = [int(color[i : i + 2], 16) / 255 for i in (1, 3, 5)] + linear = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels] + luminance = sum(v * weight for v, weight in zip(linear, (0.2126, 0.7152, 0.0722))) + return 1.05 / (luminance + 0.05) + + +def json_bytes(data: dict) -> bytes: + return (json.dumps(data, indent=2) + "\n").encode() + + +def asset_path(value: str) -> str: + path = PurePosixPath(value) + require(not path.is_absolute() and ".." not in path.parts, "Asset must stay inside package") + require(path.parts[0] == "assets", "Icons must be in assets/") + return path.as_posix() + + +def validate_package(files: dict[str, bytes], release: dict) -> None: + """Check the effective manifests and known portal rules in the actual archive content.""" + manifest = json.loads(files["plugin.json"]) + legacy = json.loads(files[".codex-plugin/plugin.json"]) + for item in (manifest, legacy): + require(item["name"] == release["plugin_id"], "Plugin name must match directory ID") + require( + stable_version(item["version"]) > stable_version(release["published_version"]), + "Package version must exceed published_version", + ) + require(not item.get("apps"), "Upload cannot declare app bindings") + require( + not item.get("extensions", {}).get("com.openai", {}).get("apps"), + "Upload cannot declare app bindings", + ) + require(manifest["version"] == legacy["version"], "Manifest versions must match") + interface = manifest["extensions"]["com.openai"]["interface"] + require(interface == legacy["interface"], "Manifest interfaces must match") + for field, limit in (("displayName", 30), ("shortDescription", 30), ("longDescription", 4000)): + require(0 < len(interface[field]) <= limit, f"{field} must be 1-{limit} characters") + require(contrast_against_white(interface["brandColor"]) >= 2, "brandColor needs 2:1 contrast") + prompts = interface["defaultPrompt"] + prompts = [prompts] if isinstance(prompts, str) else prompts + require(1 <= len(prompts) <= 3, "Expected 1-3 default prompts") + require( + all(p.strip() and len(p) <= 128 and "\n" not in p for p in prompts), + "Prompts must be nonblank single lines of at most 128 characters", + ) + require(len({" ".join(p.split()) for p in prompts}) == len(prompts), "Duplicate prompts") + for field in ("websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL"): + url = urlparse(interface[field]) + require( + url.scheme == "https" and bool(url.hostname) and not url.username and not url.password, + f"{field} must be an HTTPS URL without credentials", + ) + for field in ("logo", "composerIcon"): + path = asset_path(interface[field]) + require(path in files, f"Missing icon: {path}") + data = files[path] + require( + len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR", + "Icons must be PNG files", + ) + width, height = struct.unpack(">II", data[16:24]) + minimum = 256 if field == "logo" else 48 + require(minimum <= width == height <= 4096, "Icons must be square and correctly sized") + require(len(data) <= 5 * 1024 * 1024, "Icon exceeds 5 MiB") + portable = json.loads(files["mcp.json"])["mcpServers"] + compatibility = json.loads(files[".mcp.json"])["mcpServers"] + require(set(portable) == set(compatibility) == {"sprites"}, "Unexpected MCP server inventory") + for servers in (portable, compatibility): + server = servers["sprites"] + require("headers" not in server and "http_headers" not in server, "MCP headers unsupported") + require(server["url"] == "https://sprites.dev/mcp", "Keep the existing MCP endpoint") + require( + set(portable["sprites"]) == {"type", "url"} + and portable["sprites"]["type"] == "streamable-http", + "Unsupported portable MCP settings", + ) + require(".app.json" not in files, "Upload cannot include .app.json") + require("skills/sprites/SKILL.md" in files, "Missing Sprites skill") + require(legacy["skills"] == "./skills/", "Unexpected legacy skill path") + require(legacy["mcpServers"] == "./.mcp.json", "Unexpected legacy MCP path") + + +def build_package(root: Path, output: Path, published_version: str | None = None) -> Path: + source = root / "plugins/sprites" + release = json.loads((root / "release/directory.json").read_text()) + if published_version: + release["published_version"] = published_version + require( + bool(re.fullmatch(r"app-[0-9a-f]{32}", release["plugin_id"])), + "Expected the verified directory plugin ID", + ) + legacy = json.loads((source / ".codex-plugin/plugin.json").read_text()) + legacy["name"] = release["plugin_id"] + manifest = { + k: copy.deepcopy(v) + for k, v in legacy.items() + if k not in ("skills", "mcpServers", "interface") + } + manifest["$schema"] = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" + manifest["extensions"] = { + "com.openai": { + "interface": copy.deepcopy(legacy["interface"]), + "publication": {"release_notes": release["release_notes"]}, + } + } + config = json.loads((source / ".mcp.json").read_text()) + require(set(config["mcpServers"]) == {"sprites"}, "Unexpected source MCP server inventory") + server = config["mcpServers"]["sprites"] + require( + set(server) <= {"url", "http_headers", "headers", "tool_timeout_sec"}, + "Unsupported source MCP setting; review before packaging", + ) + server.pop("http_headers", None) + server.pop("headers", None) + portable = { + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": {"sprites": {"type": "streamable-http", "url": server["url"]}}, + } + files = { + "plugin.json": json_bytes(manifest), + ".codex-plugin/plugin.json": json_bytes(legacy), + "mcp.json": json_bytes(portable), + ".mcp.json": json_bytes(config), + "LICENSE": (root / "LICENSE").read_bytes(), + } + paths = {asset_path(legacy["interface"][field]) for field in ("logo", "composerIcon")} + paths.update(p.relative_to(source).as_posix() for p in (source / "skills").rglob("*.md")) + for relative in sorted(paths): + path = source / relative + require(path.resolve().is_relative_to(source.resolve()), "File escapes plugin directory") + require(not path.is_symlink(), "Package cannot contain symlinks") + files[relative] = path.read_bytes() + validate_package(files, release) + output.mkdir(parents=True, exist_ok=True) + archive = output / f"sprites-{manifest['version']}.zip" + # Fixed metadata and sorted paths make repeated builds byte-for-byte reproducible. + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle: + for name, data in sorted(files.items()): + info = zipfile.ZipInfo(f"{release['plugin_id']}/{name}", (2020, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + bundle.writestr(info, data) + with zipfile.ZipFile(archive) as bundle: + require(bundle.testzip() is None, "ZIP integrity check failed") + prefix = release["plugin_id"] + "/" + actual = {name.removeprefix(prefix): bundle.read(name) for name in bundle.namelist()} + require(actual == files, "ZIP content differs from prepared package") + validate_package(actual, release) + return archive + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=ROOT / "dist") + parser.add_argument("--published-version", help="Override the last confirmed published version") + args = parser.parse_args() + try: + archive = build_package(ROOT, args.output_dir, args.published_version) + except (ValueError, KeyError, OSError) as exc: + parser.exit(1, f"Packaging failed: {exc}\n") + print(f"Built and locally validated {archive}") + print("Portal acceptance, review, and publication are not verified by these checks.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_package_plugin.py b/tests/test_package_plugin.py new file mode 100644 index 0000000..494bdee --- /dev/null +++ b/tests/test_package_plugin.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from scripts.package_plugin import ROOT, build_package, json_bytes, validate_package + + +class DirectoryPackageTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.output = Path(self.temp.name) + self.release = json.loads((ROOT / "release/directory.json").read_text()) + self.archive = build_package(ROOT, self.output) + with zipfile.ZipFile(self.archive) as bundle: + prefix = self.release["plugin_id"] + "/" + self.files = { + name.removeprefix(prefix): bundle.read(name) for name in bundle.namelist() + } + + def edit_json(self, path: str, edit) -> None: + data = json.loads(self.files[path]) + edit(data) + self.files[path] = json_bytes(data) + + def edit_interfaces(self, **changes) -> None: + self.edit_json( + "plugin.json", + lambda m: m["extensions"]["com.openai"]["interface"].update(changes), + ) + self.edit_json(".codex-plugin/plugin.json", lambda m: m["interface"].update(changes)) + + def test_package_preserves_runtime_skill_and_local_configuration(self) -> None: + source = ROOT / "plugins/sprites" + self.assertEqual( + self.files["skills/sprites/SKILL.md"], (source / "skills/sprites/SKILL.md").read_bytes() + ) + local = json.loads((source / ".codex-plugin/plugin.json").read_text()) + self.assertEqual(local["name"], "sprites") + server = json.loads((source / ".mcp.json").read_text())["mcpServers"]["sprites"] + self.assertEqual(server["http_headers"]["Fly-Client-Agent"], "codex") + self.assertEqual(server["http_headers"]["Fly-Client-Interactive"], "false") + self.assertNotIn( + "http_headers", json.loads(self.files[".mcp.json"])["mcpServers"]["sprites"] + ) + self.assertNotIn("headers", json.loads(self.files["mcp.json"])["mcpServers"]["sprites"]) + + def test_reproducible_archive(self) -> None: + first = self.archive.read_bytes() + self.assertEqual(build_package(ROOT, self.output).read_bytes(), first) + + def test_rejects_wrong_directory_identity(self) -> None: + self.edit_json("plugin.json", lambda m: m.update(name="sprites")) + with self.assertRaisesRegex(ValueError, "directory ID"): + validate_package(self.files, self.release) + + def test_rejects_non_increasing_version(self) -> None: + candidate = json.loads(self.files["plugin.json"])["version"] + for published in (candidate, "99.0.0"): + with self.subTest(published=published): + with self.assertRaisesRegex(ValueError, "exceed published_version"): + build_package(ROOT, self.output, published) + + def test_rejects_mismatched_manifest_versions(self) -> None: + self.edit_json(".codex-plugin/plugin.json", lambda m: m.update(version="2.0.0")) + with self.assertRaisesRegex(ValueError, "versions must match"): + validate_package(self.files, self.release) + + def test_rejects_headers_in_either_configuration(self) -> None: + for path, key in (("mcp.json", "headers"), (".mcp.json", "http_headers")): + with self.subTest(path=path): + original = self.files[path] + self.edit_json(path, lambda m: m["mcpServers"]["sprites"].update({key: {}})) + with self.assertRaisesRegex(ValueError, "headers unsupported"): + validate_package(self.files, self.release) + self.files[path] = original + + def test_rejects_previous_low_contrast_color(self) -> None: + self.edit_interfaces(brandColor="#8AE234") + with self.assertRaisesRegex(ValueError, "2:1 contrast"): + validate_package(self.files, self.release) + + def test_rejects_previous_long_subtitle(self) -> None: + self.edit_interfaces(shortDescription="Manage remote development sprites from Codex.") + with self.assertRaisesRegex(ValueError, "shortDescription"): + validate_package(self.files, self.release) + + def test_rejects_missing_logo(self) -> None: + self.edit_interfaces(logo="./assets/missing.png") + with self.assertRaisesRegex(ValueError, "Missing icon"): + validate_package(self.files, self.release) + + def test_rejects_asset_path_escape(self) -> None: + self.edit_interfaces(logo="../outside.png") + with self.assertRaisesRegex(ValueError, "inside package"): + validate_package(self.files, self.release) + + def test_rejects_non_square_logo(self) -> None: + name = "assets/fly-logo.png" + data = bytearray(self.files[name]) + data[20:24] = (256).to_bytes(4, "big") + self.files[name] = bytes(data) + with self.assertRaisesRegex(ValueError, "square"): + validate_package(self.files, self.release) + + def test_rejects_changed_endpoint(self) -> None: + self.edit_json( + "mcp.json", lambda m: m["mcpServers"]["sprites"].update(url="https://example.com/mcp") + ) + with self.assertRaisesRegex(ValueError, "existing MCP endpoint"): + validate_package(self.files, self.release)