From c3d1d897a3a97dc23f9880d2de042cc6cc9372ef Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 02:42:20 +1300 Subject: [PATCH 1/2] feat(protocol): allow optional package icons with bounded local images --- schemas/amadeus-extension.v0.schema.json | 1 + spec/extension-protocol-v0.1.md | 11 +++++ tests/test_package_icons.py | 56 ++++++++++++++++++++++++ tools/validate_manifest.py | 32 ++++++++------ 4 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 tests/test_package_icons.py diff --git a/schemas/amadeus-extension.v0.schema.json b/schemas/amadeus-extension.v0.schema.json index 67779c1..9af6aeb 100644 --- a/schemas/amadeus-extension.v0.schema.json +++ b/schemas/amadeus-extension.v0.schema.json @@ -22,6 +22,7 @@ "version": { "$ref": "#/$defs/semver" }, "display_name": { "$ref": "#/$defs/text80" }, "description": { "$ref": "#/$defs/text500" }, + "icon": { "$ref": "#/$defs/relativePath", "description": "Optional package-local PNG or JPEG icon, at most 1 MiB. Hosts may show the name initial when absent or unavailable." }, "author": { "$ref": "#/$defs/author" }, "license": { "type": "string", "minLength": 1, "maxLength": 200 }, "homepage": { "$ref": "#/$defs/httpsUrl" }, diff --git a/spec/extension-protocol-v0.1.md b/spec/extension-protocol-v0.1.md index 51a1a18..c9776a1 100644 --- a/spec/extension-protocol-v0.1.md +++ b/spec/extension-protocol-v0.1.md @@ -254,6 +254,7 @@ Unknown fields are rejected at every level. A newer manifest format uses a new | `version` | yes | Exact Semantic Versioning 2.0.0 version | | `display_name` | yes | Localized text, at most 80 characters | | `description` | yes | Localized text, at most 500 characters | +| `icon` | no | Package-relative PNG/JPEG path; at most 1 MiB ([§3.10](#310-package-checks)) | | `author` | yes | `{ "name", "email"?, "url"? }` | | `license` | no | SPDX license expression. An index MAY require it. | | `homepage`, `repository` | no | `https://` URL | @@ -267,6 +268,15 @@ Unknown fields are rejected at every level. A newer manifest format uses a new (for example `en`, `zh-CN`, `ja`) to strings. A Host displays the entry for the user's language, then `en`, then the first entry. +Icons travel inside the package, for example `"icon": "assets/icon.png"`. +They do not name a URL or an absolute filesystem path. A square image of +256×256 pixels is recommended. The desktop accepts a decoded canvas up to +1024×1024 pixels and shows icons at 36 CSS pixels without cropping. An omitted +or unavailable icon falls back to the first displayed name character (uppercase +for Latin letters). Older Hosts may reject this optional field; authors must +declare an appropriate minimum application version in their distribution notes +until the icon-capable Host profile is published. The Host API version is unchanged. + ### 3.3 Identifiers - **Package id**: lowercase reverse-DNS form with at least two segments, for @@ -508,6 +518,7 @@ When a Host or validator has the package contents, it also checks: | P1 | The manifest exists, is at most 256 KiB, and parses as JSON. | | P2 | Each skill `path` is a directory inside the package that contains `SKILL.md`, contains and passes through no links, and is at most 16 MiB. | | P4 | Theme background resources are contained, unlinked PNG/JPEG files of at most 8 MiB. The Host also validates decoded size, token literals and rendered contrast before activation. | +| P5 | Package icons are contained, unlinked PNG/JPEG files of at most 1 MiB, with matching signatures. The Host validates declared and decoded dimensions before displaying an image. Missing, unreadable or undecodable icons at display time use the name-initial fallback without granting filesystem access to the renderer. | | P3 | Each `${package}/…` command exists inside the package without passing through links. | ## 4. Lifecycle diff --git a/tests/test_package_icons.py b/tests/test_package_icons.py new file mode 100644 index 0000000..0a6ee32 --- /dev/null +++ b/tests/test_package_icons.py @@ -0,0 +1,56 @@ +"""Optional package imagery never expands the package filesystem boundary.""" +import copy +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) +import validate_manifest as vm # noqa: E402 + + +MANIFEST = { + 'schema': 'amadeus.extension/v0', 'id': 'org.example.icon', 'version': '0.1.0', + 'display_name': 'Example', 'description': 'Icon fixture', 'author': {'name': 'Example'}, + 'engines': {'amadeus_host_api': '>=0.1,<0.2'}, 'runtime': {'kind': 'none'}, + 'contributes': {'theme': [{'id': 'plain', 'contract': 'amadeus.ui.theme/v0', 'base': 'classic', 'tokens': {}}]}, +} + + +def icon_manifest(icon): + return {**copy.deepcopy(MANIFEST), 'icon': icon} + + +def test_old_packages_need_no_icon(tmp_path): + assert not vm.manifest_issues(MANIFEST) + assert not vm.package_issues(MANIFEST, tmp_path) + + +@pytest.mark.parametrize('icon', ['https://example.com/a.png', '../a.png', '/a.png', 'C:/a.png', 'a\\b.png', '', None, 'a.svg']) +def test_icon_declaration_rejects_external_paths_and_active_formats(icon): + assert vm.manifest_issues(icon_manifest(icon)) + + +@pytest.mark.parametrize('name,header', [('icon.png', b'\x89PNG\r\n\x1a\n'), ('ICON.JPG', b'\xff\xd8\xff')]) +def test_icon_package_checks_share_image_signature_and_byte_limits(tmp_path, name, header): + manifest = icon_manifest(name) + assert not vm.manifest_issues(manifest) + assert vm.package_issues(manifest, tmp_path)[0].code == 'P3.missing_file' + image = tmp_path / name + image.write_bytes(header) + assert not vm.package_issues(manifest, tmp_path) + image.write_bytes(header + bytes(1024 * 1024)) + assert vm.package_issues(manifest, tmp_path)[0].code == 'P5.package_icon' + image.write_bytes(b'') + assert vm.package_issues(manifest, tmp_path)[0].code == 'P5.package_icon' + + +@pytest.mark.skipif(os.name == 'nt', reason='Windows links are covered by the native Host junction test') +def test_icon_cannot_follow_a_link_out_of_its_package(tmp_path): + package = tmp_path / 'package' + package.mkdir() + outside = tmp_path / 'outside.png' + outside.write_bytes(b'\x89PNG\r\n\x1a\n') + (package / 'icon.png').symlink_to(outside) + assert vm.package_issues(icon_manifest('icon.png'), package)[0].code == 'P2.link_in_package' diff --git a/tools/validate_manifest.py b/tools/validate_manifest.py index 7e83752..76d9ef2 100644 --- a/tools/validate_manifest.py +++ b/tools/validate_manifest.py @@ -171,6 +171,8 @@ def semantic_issues(manifest: dict[str, Any]) -> list[Issue]: "an entry is allowed only when the package contributes a runner kind", )) + if 'icon' in manifest and Path(manifest['icon']).suffix.lower() not in {'.png', '.jpg', '.jpeg'}: + issues.append(Issue('P5.package_icon', '/icon', 'Package icons must be PNG/JPEG')) for index, theme in enumerate(contributes.get('theme', [])): background = theme.get('background') if background and Path(background['image']).suffix.lower() not in {'.png', '.jpg', '.jpeg'}: @@ -355,23 +357,13 @@ def package_issues(manifest: dict[str, Any], package_dir: Path) -> list[Issue]: issues = [] for index, skill in enumerate(manifest["contributes"].get("skill", [])): issues.extend(_skill_issues(f"/contributes/skill/{index}/path", package_dir, skill["path"])) + if 'icon' in manifest: + issues.extend(_package_image_issues('/icon', package_dir, manifest['icon'], 1024 * 1024, 'P5.package_icon')) for index, theme in enumerate(manifest['contributes'].get('theme', [])): background = theme.get('background') if background: pointer = f'/contributes/theme/{index}/background/image' - found = _package_file_issues(pointer, package_dir, background['image']) - issues.extend(found) - if not found: - image = package_dir / background['image'] - if image.suffix.lower() not in {'.png', '.jpg', '.jpeg'} or image.stat().st_size > 8 * 1024 * 1024: - issues.append(Issue('P4.theme_image', pointer, 'Theme images must be PNG/JPEG, at most 8 MiB')) - else: - with image.open('rb') as stream: - header = stream.read(16) - matches = (image.suffix.lower() == '.png' and header.startswith(b'\x89PNG\r\n\x1a\n') - or image.suffix.lower() in {'.jpg', '.jpeg'} and header.startswith(b'\xff\xd8\xff')) - if not matches: - issues.append(Issue('P4.theme_image', pointer, 'Theme image signature does not match its file type')) + issues.extend(_package_image_issues(pointer, package_dir, background['image'], 8 * 1024 * 1024, 'P4.theme_image')) for index, server in enumerate(manifest["contributes"].get("mcp_server", [])): command = server.get("command", "") if server["transport"] != "stdio" or not command.startswith(_PACKAGE_PREFIX): @@ -382,6 +374,20 @@ def package_issues(manifest: dict[str, Any], package_dir: Path) -> list[Issue]: return issues +def _package_image_issues(pointer: str, package_dir: Path, relative: str, limit: int, code: str) -> list[Issue]: + issues = _package_file_issues(pointer, package_dir, relative) + if issues: + return issues + image = package_dir / relative + if image.suffix.lower() not in {'.png', '.jpg', '.jpeg'} or image.stat().st_size > limit: + return [Issue(code, pointer, f'Image must be PNG/JPEG, at most {limit // (1024 * 1024)} MiB')] + with image.open('rb') as stream: + header = stream.read(16) + matches = (image.suffix.lower() == '.png' and header.startswith(b'\x89PNG\r\n\x1a\n') + or image.suffix.lower() in {'.jpg', '.jpeg'} and header.startswith(b'\xff\xd8\xff')) + return [] if matches else [Issue(code, pointer, 'Image signature does not match its file type')] + + def _walk_to(pointer: str, package_dir: Path, relative: str) -> tuple[Path | None, list[Issue]]: target = package_dir for part in relative.split("/"): From b444dd8b452a914f68cdba439cc3e79a9a1e53cf Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Fri, 23 Oct 2026 04:19:06 +1300 Subject: [PATCH 2/2] fix(protocol): introduce package icons under manifest v1 --- README.md | 3 +- schemas/amadeus-extension.v0.schema.json | 1 - schemas/amadeus-extension.v1.schema.json | 530 +++++++++++++++++++++++ spec/extension-protocol-v0.1.md | 40 +- tests/test_manifest_versions.py | 43 ++ tests/test_manifests.py | 7 +- tests/test_package_icons.py | 4 +- tools/validate_manifest.py | 25 +- 8 files changed, 624 insertions(+), 29 deletions(-) create mode 100644 schemas/amadeus-extension.v1.schema.json create mode 100644 tests/test_manifest_versions.py diff --git a/README.md b/README.md index 5a99d97..54ea4a4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Our current focus is how extensions fit into Amadeus capability discovery, routi | [Desktop themes](spec/contracts/theme-v0.md) | Data-only palettes, installed fonts, bounded images and package lifecycle (provisional) | | [Context sources](spec/contracts/context-source-v0.md) | Reference material such as memories for Main Chat turns (provisional) | | [Observers](spec/contracts/observer-v0.md) | Projected Host events (provisional) | -| [Manifest schema](schemas/amadeus-extension.v0.schema.json) | JSON Schema for `amadeus-extension.json` | +| [Manifest v0 schema](schemas/amadeus-extension.v0.schema.json) | Existing manifests; no icon field | +| [Manifest v1 schema](schemas/amadeus-extension.v1.schema.json) | Adds optional local package icons; new Hosts accept v0 and v1 | | [Examples](examples/) | Six manifest examples; the experimental Host supports only its documented subset | In short: diff --git a/schemas/amadeus-extension.v0.schema.json b/schemas/amadeus-extension.v0.schema.json index 9af6aeb..67779c1 100644 --- a/schemas/amadeus-extension.v0.schema.json +++ b/schemas/amadeus-extension.v0.schema.json @@ -22,7 +22,6 @@ "version": { "$ref": "#/$defs/semver" }, "display_name": { "$ref": "#/$defs/text80" }, "description": { "$ref": "#/$defs/text500" }, - "icon": { "$ref": "#/$defs/relativePath", "description": "Optional package-local PNG or JPEG icon, at most 1 MiB. Hosts may show the name initial when absent or unavailable." }, "author": { "$ref": "#/$defs/author" }, "license": { "type": "string", "minLength": 1, "maxLength": 200 }, "homepage": { "$ref": "#/$defs/httpsUrl" }, diff --git a/schemas/amadeus-extension.v1.schema.json b/schemas/amadeus-extension.v1.schema.json new file mode 100644 index 0000000..2977fe7 --- /dev/null +++ b/schemas/amadeus-extension.v1.schema.json @@ -0,0 +1,530 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Code-Amadeus/Amadeus-Extensions/main/schemas/amadeus-extension.v1.schema.json", + "title": "Amadeus extension manifest (amadeus.extension/v1)", + "description": "Manifest format defined by the Amadeus Extension Protocol v0.1 draft. Rules that this schema does not express are listed in spec/extension-protocol-v0.1.md section 3.9 and implemented by tools/validate_manifest.py.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "id", + "version", + "display_name", + "description", + "author", + "engines", + "runtime", + "contributes" + ], + "properties": { + "schema": { "const": "amadeus.extension/v1" }, + "id": { "$ref": "#/$defs/packageId" }, + "version": { "$ref": "#/$defs/semver" }, + "display_name": { "$ref": "#/$defs/text80" }, + "description": { "$ref": "#/$defs/text500" }, + "icon": { "$ref": "#/$defs/relativePath", "description": "Optional package-local PNG or JPEG icon, at most 1 MiB. Hosts may show the name initial when absent or unavailable." }, + "author": { "$ref": "#/$defs/author" }, + "license": { "type": "string", "minLength": 1, "maxLength": 200 }, + "homepage": { "$ref": "#/$defs/httpsUrl" }, + "repository": { "$ref": "#/$defs/httpsUrl" }, + "engines": { "$ref": "#/$defs/engines" }, + "runtime": { "$ref": "#/$defs/runtime" }, + "contributes": { "$ref": "#/$defs/contributes" }, + "config": { "$ref": "#/$defs/config" }, + "permissions": { "$ref": "#/$defs/permissions" } + }, + "$defs": { + "packageId": { + "description": "Lowercase reverse-DNS package id. The amadeus. prefix is reserved for built-in packages.", + "type": "string", + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*(\\.[a-z0-9][a-z0-9-]*)+$", + "not": { "pattern": "^amadeus\\." } + }, + "contributionId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "configKey": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,63}$" + }, + "semver": { + "description": "Semantic Versioning 2.0.0 version.", + "type": "string", + "maxLength": 128, + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\\+([0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*))?$" + }, + "versionRange": { + "description": "Comma-separated comparators that must all hold, for example '>=0.1,<0.2'.", + "type": "string", + "maxLength": 200, + "pattern": "^\\s*(>=|<=|==|!=|>|<)\\s*[0-9]+(\\.[0-9]+)*(\\s*,\\s*(>=|<=|==|!=|>|<)\\s*[0-9]+(\\.[0-9]+)*)*\\s*$" + }, + "entryRef": { + "description": "Python object reference in module:attribute form.", + "type": "string", + "maxLength": 256, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*:[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$" + }, + "languageTag": { + "type": "string", + "pattern": "^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$" + }, + "languages": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { "$ref": "#/$defs/languageTag" } + }, + "httpsUrl": { + "type": "string", + "maxLength": 2048, + "pattern": "^https://[^\\s]+$" + }, + "relativePath": { + "description": "Package-relative path with forward slashes. Segments may not start with a dot, which also excludes '..'.", + "type": "string", + "maxLength": 256, + "pattern": "^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*)*$" + }, + "text80": { + "oneOf": [ + { "type": "string", "minLength": 1, "maxLength": 80 }, + { + "type": "object", + "minProperties": 1, + "propertyNames": { "$ref": "#/$defs/languageTag" }, + "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 80 } + } + ] + }, + "text200": { + "oneOf": [ + { "type": "string", "minLength": 1, "maxLength": 200 }, + { + "type": "object", + "minProperties": 1, + "propertyNames": { "$ref": "#/$defs/languageTag" }, + "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 200 } + } + ] + }, + "text500": { + "oneOf": [ + { "type": "string", "minLength": 1, "maxLength": 500 }, + { + "type": "object", + "minProperties": 1, + "propertyNames": { "$ref": "#/$defs/languageTag" }, + "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 500 } + } + ] + }, + "author": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "email": { "type": "string", "maxLength": 320, "pattern": "^[^@\\s]+@[^@\\s]+$" }, + "url": { "$ref": "#/$defs/httpsUrl" } + } + }, + "engines": { + "type": "object", + "additionalProperties": false, + "required": ["amadeus_host_api"], + "properties": { + "amadeus_host_api": { "$ref": "#/$defs/versionRange" }, + "platforms": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["win32", "darwin", "linux"] } + } + } + }, + "runtime": { + "type": "object", + "required": ["kind"], + "properties": { "kind": { "enum": ["none", "python"] } }, + "if": { "properties": { "kind": { "const": "python" } } }, + "then": { "$ref": "#/$defs/pythonRuntime" }, + "else": { "$ref": "#/$defs/noneRuntime" } + }, + "noneRuntime": { + "type": "object", + "additionalProperties": false, + "properties": { "kind": { "const": "none" } } + }, + "pythonRuntime": { + "type": "object", + "additionalProperties": false, + "required": ["requires_python"], + "properties": { + "kind": { "const": "python" }, + "entry": { "$ref": "#/$defs/entryRef" }, + "requires_python": { "$ref": "#/$defs/versionRange" } + } + }, + "contributes": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "theme": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { "$ref": "#/$defs/theme" } + }, + "voice_backend": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { "$ref": "#/$defs/voiceBackend" } + }, + "skill": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { "$ref": "#/$defs/skill" } + }, + "mcp_server": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { "$ref": "#/$defs/mcpServer" } + }, + "context_source": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/contextSource" } + }, + "observer": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/observer" } + } + } + }, + "theme": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "base", "tokens"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.ui.theme/v0" }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" }, + "base": { "enum": ["classic", "wallpaper-slice"] }, + "tokens": { + "type": "object", + "maxProperties": 32, + "propertyNames": { "pattern": "^--[a-z][a-z-]{0,63}$" }, + "additionalProperties": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "background": { + "type": "object", + "additionalProperties": false, + "required": ["image", "fit", "dim"], + "properties": { + "image": { "$ref": "#/$defs/relativePath" }, + "fit": { "enum": ["cover", "contain"] }, + "dim": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + } + }, + "voiceBackend": { + "type": "object", + "required": ["role"], + "properties": { "role": { "enum": ["tts", "asr"] } }, + "if": { "properties": { "role": { "const": "tts" } } }, + "then": { "$ref": "#/$defs/ttsBackend" }, + "else": { "$ref": "#/$defs/asrBackend" } + }, + "ttsBackend": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "role", "deployment", "languages", "streaming"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.voice.tts/v0" }, + "role": { "const": "tts" }, + "deployment": { "enum": ["local", "remote"] }, + "languages": { "$ref": "#/$defs/languages" }, + "streaming": { "type": "boolean" }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" } + } + }, + "asrBackend": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "role", "deployment", "languages", "speculative"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.voice.asr/v0" }, + "role": { "const": "asr" }, + "deployment": { "enum": ["local", "remote"] }, + "languages": { "$ref": "#/$defs/languages" }, + "speculative": { "type": "boolean" }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" } + } + }, + "skill": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "path"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.skill/v0" }, + "path": { "$ref": "#/$defs/relativePath" }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" } + } + }, + "mcpServer": { + "type": "object", + "required": ["transport"], + "properties": { "transport": { "enum": ["stdio", "http"] } }, + "if": { "properties": { "transport": { "const": "stdio" } } }, + "then": { "$ref": "#/$defs/mcpStdio" }, + "else": { "$ref": "#/$defs/mcpHttp" } + }, + "mcpStdio": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "transport", "command"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.mcp-server/v0" }, + "transport": { "const": "stdio" }, + "command": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "args": { + "type": "array", + "maxItems": 64, + "items": { "type": "string", "maxLength": 4096 } + }, + "env": { + "type": "object", + "maxProperties": 64, + "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$" }, + "additionalProperties": { "type": "string", "maxLength": 16384 } + }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" } + } + }, + "mcpHttp": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "transport", "url"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.mcp-server/v0" }, + "transport": { "const": "http" }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(https?://|\\$\\{config\\.[a-z][a-z0-9_]{0,63}\\})" + }, + "bearer_token": { + "type": "string", + "pattern": "^\\$\\{config\\.[a-z][a-z0-9_]{0,63}\\}$" + }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" } + } + }, + "contextSource": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "category", "max_tokens", "deadline_ms"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.context.source/v0" }, + "category": { "enum": ["memory", "knowledge", "situation"] }, + "max_tokens": { "type": "integer", "minimum": 1, "maximum": 2000 }, + "deadline_ms": { "type": "integer", "minimum": 20, "maximum": 500 }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" } + } + }, + "observer": { + "type": "object", + "additionalProperties": false, + "required": ["id", "contract", "topics"], + "properties": { + "id": { "$ref": "#/$defs/contributionId" }, + "contract": { "const": "amadeus.events.observer/v0" }, + "topics": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["work.item.updated", "conversation.turn.completed"] } + }, + "title": { "$ref": "#/$defs/text80" }, + "summary": { "$ref": "#/$defs/text500" } + } + }, + "config": { + "type": "object", + "maxProperties": 64, + "propertyNames": { "$ref": "#/$defs/configKey" }, + "additionalProperties": { "$ref": "#/$defs/configField" } + }, + "configField": { + "type": "object", + "additionalProperties": false, + "required": ["type", "title"], + "properties": { + "type": { "enum": ["string", "url", "boolean", "integer", "number", "enum"] }, + "title": { "$ref": "#/$defs/text80" }, + "description": { "$ref": "#/$defs/text500" }, + "required": { "type": "boolean" }, + "default": true, + "secret": { "type": "boolean" }, + "options": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { "$ref": "#/$defs/configOption" } + }, + "min": { "type": "number" }, + "max": { "type": "number" }, + "max_length": { "type": "integer", "minimum": 1, "maximum": 16384 }, + "apply": { "enum": ["live", "reactivate"] } + }, + "allOf": [ + { + "if": { "required": ["secret"], "properties": { "secret": { "const": true } } }, + "then": { + "properties": { "type": { "const": "string" }, "default": { "not": {} } } + } + }, + { + "if": { "properties": { "type": { "const": "enum" } } }, + "then": { + "required": ["options"], + "properties": { "default": { "type": "string" } } + }, + "else": { "properties": { "options": { "not": {} } } } + }, + { + "if": { "properties": { "type": { "enum": ["integer", "number"] } } }, + "else": { "properties": { "min": { "not": {} }, "max": { "not": {} } } } + }, + { + "if": { "properties": { "type": { "enum": ["string", "url"] } } }, + "then": { "properties": { "default": { "type": "string" } } }, + "else": { "properties": { "max_length": { "not": {} } } } + }, + { + "if": { "properties": { "type": { "const": "boolean" } } }, + "then": { "properties": { "default": { "type": "boolean" } } } + }, + { + "if": { "properties": { "type": { "const": "integer" } } }, + "then": { + "properties": { + "default": { "type": "integer" }, + "min": { "type": "integer" }, + "max": { "type": "integer" } + } + } + }, + { + "if": { "properties": { "type": { "const": "number" } } }, + "then": { "properties": { "default": { "type": "number" } } } + } + ] + }, + "configOption": { + "oneOf": [ + { "type": "string", "minLength": 1, "maxLength": 200 }, + { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { "type": "string", "minLength": 1, "maxLength": 200 }, + "label": { "$ref": "#/$defs/text80" } + } + } + ] + }, + "permissions": { + "type": "object", + "additionalProperties": false, + "properties": { + "host": { + "type": "array", + "maxItems": 32, + "items": { "$ref": "#/$defs/hostPermission" } + }, + "os": { "$ref": "#/$defs/osDisclosure" } + } + }, + "hostPermission": { + "type": "object", + "additionalProperties": false, + "required": ["capability", "reason"], + "properties": { + "capability": { + "enum": [ + "conversation.current:read", + "conversation.history:read", + "events.work:read", + "presentation:notify" + ] + }, + "reason": { "$ref": "#/$defs/text200" } + } + }, + "osDisclosure": { + "type": "object", + "additionalProperties": false, + "properties": { + "network": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(\\*|(\\*\\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*)$" + } + }, + "filesystem": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "access"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "access": { "enum": ["read", "read_write"] } + } + } + }, + "process": { + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(\\*|[A-Za-z0-9][A-Za-z0-9._-]{0,127})$" + } + } + } + } + } +} diff --git a/spec/extension-protocol-v0.1.md b/spec/extension-protocol-v0.1.md index c9776a1..8fd0a32 100644 --- a/spec/extension-protocol-v0.1.md +++ b/spec/extension-protocol-v0.1.md @@ -20,7 +20,8 @@ Companion documents: | [Context source contract](contracts/context-source-v0.md) (`amadeus.context.source/v0`) | Provisional | | [Observer contract](contracts/observer-v0.md) (`amadeus.events.observer/v0`) | Provisional | | [Theme contract](contracts/theme-v0.md) (`amadeus.ui.theme/v0`) | Provisional; local P1/P2 implementation under review | -| [Manifest JSON Schema](../schemas/amadeus-extension.v0.schema.json) | Normative | +| [Manifest v0 JSON Schema](../schemas/amadeus-extension.v0.schema.json) | Normative; no package icon field | +| [Manifest v1 JSON Schema](../schemas/amadeus-extension.v1.schema.json) | Normative; adds optional package icons | | [Reference manifest validator](../tools/validate_manifest.py) | Informative | | [Example packages](../examples/) | Informative | @@ -237,10 +238,17 @@ contribution kinds. A package carries one manifest named `amadeus-extension.json` (see [§8.1](#81-package-forms) for its location). The manifest is UTF-8 JSON without -a byte order mark and at most 256 KiB. A valid manifest validates against -[`schemas/amadeus-extension.v0.schema.json`](../schemas/amadeus-extension.v0.schema.json) -and satisfies the rules in [§3.9](#39-semantic-rules). The Host reads the -manifest without executing package code. +a byte order mark and at most 256 KiB. A valid manifest validates against the +schema selected by its `schema` value and satisfies [§3.9](#39-semantic-rules): + +| Manifest value | Structural rules | +| --- | --- | +| `amadeus.extension/v0` | [Frozen v0 schema](../schemas/amadeus-extension.v0.schema.json); `icon` is not allowed | +| `amadeus.extension/v1` | [v1 schema](../schemas/amadeus-extension.v1.schema.json); adds optional `icon` | + +The Host reads the manifest without executing package code. Hosts implementing +this revision accept both versions; existing v0 packages need no migration. +The manifest version does not change contribution contracts or the Runner SDK. Unknown fields are rejected at every level. A newer manifest format uses a new `schema` value instead of adding fields that older Hosts would misread. @@ -249,12 +257,12 @@ Unknown fields are rejected at every level. A newer manifest format uses a new | Field | Required | Value | | --- | --- | --- | -| `schema` | yes | `"amadeus.extension/v0"` | +| `schema` | yes | `"amadeus.extension/v0"` or `"amadeus.extension/v1"` | | `id` | yes | Package id ([§3.3](#33-identifiers)) | | `version` | yes | Exact Semantic Versioning 2.0.0 version | | `display_name` | yes | Localized text, at most 80 characters | | `description` | yes | Localized text, at most 500 characters | -| `icon` | no | Package-relative PNG/JPEG path; at most 1 MiB ([§3.10](#310-package-checks)) | +| `icon` | no; v1 only | Package-relative PNG/JPEG path; at most 1 MiB ([§3.10](#310-package-checks)) | | `author` | yes | `{ "name", "email"?, "url"? }` | | `license` | no | SPDX license expression. An index MAY require it. | | `homepage`, `repository` | no | `https://` URL | @@ -268,14 +276,20 @@ Unknown fields are rejected at every level. A newer manifest format uses a new (for example `en`, `zh-CN`, `ja`) to strings. A Host displays the entry for the user's language, then `en`, then the first entry. -Icons travel inside the package, for example `"icon": "assets/icon.png"`. -They do not name a URL or an absolute filesystem path. A square image of +To declare an icon, set `"schema": "amadeus.extension/v1"` and include a +package-local path such as `"icon": "assets/icon.png"`. The v0 schema is unchanged +and rejects `icon`. Icons do not name a URL or an absolute filesystem path. A square image of 256×256 pixels is recommended. The desktop accepts a decoded canvas up to 1024×1024 pixels and shows icons at 36 CSS pixels without cropping. An omitted or unavailable icon falls back to the first displayed name character (uppercase -for Latin letters). Older Hosts may reject this optional field; authors must -declare an appropriate minimum application version in their distribution notes -until the icon-capable Host profile is published. The Host API version is unchanged. +for Latin letters). Older Hosts that only implement v0 reject v1 manifests, +including v1 manifests without an icon. In particular, existing Hosts validate +the schema before checking `engines`, and may report `invalid manifest` rather +than `incompatible`; a higher Host API requirement cannot change their behavior. +Authors must state a v1-capable minimum application version in distribution +notes. The Host API version remains unchanged because runtime contracts are unchanged. +Catalog admission is a separate gate: icon packages must not enter a catalog +until its pinned checker has been qualified for v1. ### 3.3 Identifiers @@ -517,9 +531,9 @@ When a Host or validator has the package contents, it also checks: | --- | --- | | P1 | The manifest exists, is at most 256 KiB, and parses as JSON. | | P2 | Each skill `path` is a directory inside the package that contains `SKILL.md`, contains and passes through no links, and is at most 16 MiB. | +| P3 | Each `${package}/…` command exists inside the package without passing through links. | | P4 | Theme background resources are contained, unlinked PNG/JPEG files of at most 8 MiB. The Host also validates decoded size, token literals and rendered contrast before activation. | | P5 | Package icons are contained, unlinked PNG/JPEG files of at most 1 MiB, with matching signatures. The Host validates declared and decoded dimensions before displaying an image. Missing, unreadable or undecodable icons at display time use the name-initial fallback without granting filesystem access to the renderer. | -| P3 | Each `${package}/…` command exists inside the package without passing through links. | ## 4. Lifecycle diff --git a/tests/test_manifest_versions.py b/tests/test_manifest_versions.py new file mode 100644 index 0000000..80a7416 --- /dev/null +++ b/tests/test_manifest_versions.py @@ -0,0 +1,43 @@ +"""A new manifest value opts into icons without widening the frozen v0 format.""" +import copy +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import validate_manifest as vm # noqa: E402 + +EXAMPLES = sorted((ROOT / 'examples').glob('*/amadeus-extension.json')) + + +@pytest.mark.parametrize('example', EXAMPLES, ids=lambda path: path.parent.name) +@pytest.mark.parametrize('schema', ['amadeus.extension/v0', 'amadeus.extension/v1']) +def test_existing_contributions_remain_valid_under_both_versions(example, schema): + manifest = json.loads(example.read_text(encoding='utf-8')) + manifest['schema'] = schema + assert not vm.manifest_issues(manifest) + assert not vm.package_issues(manifest, example.parent) + + +@pytest.mark.parametrize('schema', ['amadeus.extension/v0', 'amadeus.extension/v1']) +def test_only_v1_accepts_icon_and_both_versions_reject_unknown_fields(schema): + manifest = json.loads(EXAMPLES[0].read_text(encoding='utf-8')) + manifest['schema'] = schema + manifest['icon'] = 'icon.png' + assert bool(vm.manifest_issues(manifest)) == (schema == 'amadeus.extension/v0') + del manifest['icon'] + for target in (manifest, manifest['author']): + target['undeclared_field'] = 'not an extension point' + assert {issue.code for issue in vm.manifest_issues(manifest)} == {'schema'} + del target['undeclared_field'] + + +@pytest.mark.parametrize('schema', ['amadeus.extension/v2', '../v0', '', None, [], {}]) +def test_unknown_or_malformed_schema_is_rejected_without_loading_a_path(schema): + manifest = json.loads(EXAMPLES[0].read_text(encoding='utf-8')) + manifest['schema'] = copy.deepcopy(schema) + issues = vm.manifest_issues(manifest) + assert [(issue.code, issue.path) for issue in issues] == [('schema', '/schema')] diff --git a/tests/test_manifests.py b/tests/test_manifests.py index a984a7f..44fe8b6 100644 --- a/tests/test_manifests.py +++ b/tests/test_manifests.py @@ -57,8 +57,9 @@ def mutate(manifest: dict) -> None: } -def test_schema_is_a_valid_draft_2020_12_schema(): - vm._validator() +@pytest.mark.parametrize("schema_name", ["amadeus.extension/v0", "amadeus.extension/v1"]) +def test_schema_is_a_valid_draft_2020_12_schema(schema_name): + vm._validator(schema_name) @pytest.mark.parametrize("package", EXAMPLES, ids=lambda path: path.name) @@ -288,7 +289,7 @@ def test_theme_background_requires_a_contained_package_file(tmp_path): manifest = example('desktop-theme') manifest['contributes']['theme'][0]['background'] = {'image': 'bg.png', 'fit': 'cover', 'dim': .4} assert not codes(manifest) - assert {issue.code for issue in vm.package_issues(manifest, tmp_path)} == {'P3.missing_file'} + assert {issue.code for issue in vm.package_issues(manifest, tmp_path)} == {'P4.theme_image'} (tmp_path / 'bg.png').write_bytes(b'Host separately validates image encoding and decoded dimensions') assert 'P4.theme_image' in {issue.code for issue in vm.package_issues(manifest, tmp_path)} (tmp_path / 'bg.png').write_bytes(b'\x89PNG\r\n\x1a\n' + b'header-only fixture; full decoding remains Host-owned') diff --git a/tests/test_package_icons.py b/tests/test_package_icons.py index 0a6ee32..f6b24a2 100644 --- a/tests/test_package_icons.py +++ b/tests/test_package_icons.py @@ -19,7 +19,7 @@ def icon_manifest(icon): - return {**copy.deepcopy(MANIFEST), 'icon': icon} + return {**copy.deepcopy(MANIFEST), 'schema': 'amadeus.extension/v1', 'icon': icon} def test_old_packages_need_no_icon(tmp_path): @@ -36,7 +36,7 @@ def test_icon_declaration_rejects_external_paths_and_active_formats(icon): def test_icon_package_checks_share_image_signature_and_byte_limits(tmp_path, name, header): manifest = icon_manifest(name) assert not vm.manifest_issues(manifest) - assert vm.package_issues(manifest, tmp_path)[0].code == 'P3.missing_file' + assert vm.package_issues(manifest, tmp_path)[0].code == 'P5.package_icon' image = tmp_path / name image.write_bytes(header) assert not vm.package_issues(manifest, tmp_path) diff --git a/tools/validate_manifest.py b/tools/validate_manifest.py index 76d9ef2..5bf14c9 100644 --- a/tools/validate_manifest.py +++ b/tools/validate_manifest.py @@ -8,8 +8,8 @@ contains one. A directory is also checked for the files its manifest refers to. The exit status is 0 when every PATH is valid and 1 otherwise. -The JSON Schema in ``schemas/amadeus-extension.v0.schema.json`` holds the -structural rules. This module adds the semantic rules of spec section 3.9, +The versioned JSON Schemas in ``schemas/amadeus-extension.v*.schema.json`` +hold the structural rules selected by the manifest schema identifier. This module adds the semantic rules of spec section 3.9, which JSON Schema cannot express, and the package checks of section 3.10. """ @@ -34,6 +34,10 @@ SCHEMA_PATH = ROOT / "schemas" / "amadeus-extension.v0.schema.json" THEME_TOKENS = json.loads(SCHEMA_PATH.with_name("theme-tokens.v0.json").read_text(encoding="utf-8")) MANIFEST_NAME = "amadeus-extension.json" +SCHEMA_PATHS = { + "amadeus.extension/v0": SCHEMA_PATH, + "amadeus.extension/v1": SCHEMA_PATH.with_name("amadeus-extension.v1.schema.json"), +} MANIFEST_LIMIT_BYTES = 256 * 1024 SKILL_LIMIT_BYTES = 16 * 1024 * 1024 @@ -59,9 +63,9 @@ def __str__(self) -> str: return f"{self.code} {self.path}: {self.message}" -@lru_cache(maxsize=1) -def _validator() -> Draft202012Validator: - schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) +@lru_cache(maxsize=2) +def _validator(schema_name: str) -> Draft202012Validator: + schema = json.loads(SCHEMA_PATHS[schema_name].read_text(encoding="utf-8")) Draft202012Validator.check_schema(schema) return Draft202012Validator(schema) @@ -79,8 +83,11 @@ def _message(error: Any) -> str: def schema_issues(manifest: Any) -> list[Issue]: + schema_name = manifest.get("schema") if isinstance(manifest, dict) else None + if not isinstance(schema_name, str) or schema_name not in SCHEMA_PATHS: + return [Issue("schema", "/schema", "supported manifest schemas: " + ", ".join(SCHEMA_PATHS))] issues = [] - for error in _validator().iter_errors(manifest): + for error in _validator(schema_name).iter_errors(manifest): # oneOf failures carry the useful explanation in their sub-errors. detail = best_match(error.context) if error.context else error issues.append(Issue("schema", _pointer(detail.absolute_path), _message(detail))) @@ -375,7 +382,7 @@ def package_issues(manifest: dict[str, Any], package_dir: Path) -> list[Issue]: def _package_image_issues(pointer: str, package_dir: Path, relative: str, limit: int, code: str) -> list[Issue]: - issues = _package_file_issues(pointer, package_dir, relative) + issues = _package_file_issues(pointer, package_dir, relative, missing_code=code) if issues: return issues image = package_dir / relative @@ -418,12 +425,12 @@ def _skill_issues(pointer: str, package_dir: Path, relative: str) -> list[Issue] return [] -def _package_file_issues(pointer: str, package_dir: Path, relative: str) -> list[Issue]: +def _package_file_issues(pointer: str, package_dir: Path, relative: str, *, missing_code: str = "P3.missing_file") -> list[Issue]: target, issues = _walk_to(pointer, package_dir, relative) if target is None: return issues if not target.is_file(): - return [Issue("P3.missing_file", pointer, f"{relative} does not exist in the package")] + return [Issue(missing_code, pointer, f"{relative} does not exist in the package")] return []