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.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 51a1a18..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,11 +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; 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 | @@ -267,6 +276,21 @@ 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. +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 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 - **Package id**: lowercase reverse-DNS form with at least two segments, for @@ -507,8 +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. | -| 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. | | 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. | ## 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 new file mode 100644 index 0000000..f6b24a2 --- /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), 'schema': 'amadeus.extension/v1', '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 == 'P5.package_icon' + 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..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))) @@ -171,6 +178,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 +364,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 +381,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, missing_code=code) + 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("/"): @@ -412,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 []