diff --git a/README.md b/README.md index 2758f93..5a99d97 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Public design direction and discussion for the Amadeus extension system. > **Status: Draft specification v0.1** > -> A first draft of the extension protocol is available for discussion. An unmerged Amadeus experimental branch implements a restart-based TTS/MCP/Skill subset; no stable SDK has been released. Interfaces and compatibility guarantees may change until 1.0. +> A first draft of the extension protocol is available for discussion. Unmerged Amadeus experiments implement TTS/MCP/Skill lifecycle and data-only desktop themes; no stable SDK has been released. Interfaces and compatibility guarantees may change until 1.0. ## Why this repository exists @@ -18,17 +18,18 @@ Our current focus is how extensions fit into Amadeus capability discovery, routi | --- | --- | | [Extension Protocol v0.1](spec/extension-protocol-v0.1.md) | Manifest, lifecycle, permissions, Runner protocol, packaging | | [Voice backends](spec/contracts/voice-v0.md) | TTS backends (provisional) and ASR backends (provisional) | +| [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` | -| [Examples](examples/) | Five example packages covering every contribution kind | +| [Examples](examples/) | Six manifest examples; the experimental Host supports only its documented subset | In short: - An extension is a package with one `amadeus-extension.json`. It never imports Amadeus internals. -- Extension code runs in its own process and Python environment and reaches Amadeus only through the protocol. +- Process contributions run in their own process and Python environment and reach Amadeus only through the protocol. Resource-only packages do not execute code during preparation. - The first experimental profile rejects all Host capability requests and stores no Grants; broader permission design is provisional. -- In the Amadeus experiment, installation, configuration, explicit version updates, enable/disable, selection and removal take effect after restarting the backend, consistently with built-in settings. Runner crash recovery stays inside the proxy. +- Pure frontend packages apply immediately. Packages with backend contributions retain one loaded version until backend restart. Disabling or removing a mixed package closes its frontend contributions immediately while clearly reporting the old backend awaiting shutdown. Runner crash recovery stays inside the proxy. Agent Skills can be installed once and bound to selected Codex, ACP and Pi Agents in the experimental Host. Unchanged native sessions resume; changing diff --git a/examples/desktop-theme/amadeus-extension.json b/examples/desktop-theme/amadeus-extension.json new file mode 100644 index 0000000..fcdb849 --- /dev/null +++ b/examples/desktop-theme/amadeus-extension.json @@ -0,0 +1,44 @@ +{ + "license": "MIT-0", + "schema": "amadeus.extension/v0", + "id": "org.example.desktop-theme", + "version": "0.1.0", + "display_name": { + "en": "Quiet desktop", + "zh-CN": "静谧桌面" + }, + "description": "A declarative desktop palette; no package code runs.", + "author": { + "name": "Amadeus contributors" + }, + "engines": { + "amadeus_host_api": ">=0.1,<0.2" + }, + "runtime": { + "kind": "none" + }, + "contributes": { + "theme": [ + { + "id": "night", + "contract": "amadeus.ui.theme/v0", + "base": "wallpaper-slice", + "title": { + "en": "Quiet night", + "zh-CN": "静谧夜色" + }, + "tokens": { + "--bg": "rgba(6,17,22,.85)", + "--surface": "#0A1D22", + "--surface-alt": "#0D252A", + "--text": "#E0F8F1", + "--muted": "#A3CFC4", + "--accent": "#7DE0CC", + "--accent-hover": "#62BDA8", + "--faint": "#A3CFC4", + "--selection-bg": "rgba(84,255,221,.10)" + } + } + ] + } +} diff --git a/schemas/amadeus-extension.v0.schema.json b/schemas/amadeus-extension.v0.schema.json index 4ce930e..67779c1 100644 --- a/schemas/amadeus-extension.v0.schema.json +++ b/schemas/amadeus-extension.v0.schema.json @@ -173,6 +173,12 @@ "additionalProperties": false, "minProperties": 1, "properties": { + "theme": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { "$ref": "#/$defs/theme" } + }, "voice_backend": { "type": "array", "minItems": 1, @@ -205,6 +211,34 @@ } } }, + "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"], diff --git a/schemas/theme-tokens.v0.json b/schemas/theme-tokens.v0.json new file mode 100644 index 0000000..79cca1d --- /dev/null +++ b/schemas/theme-tokens.v0.json @@ -0,0 +1,27 @@ +{ + "--bg": "color", + "--surface": "color", + "--surface-alt": "color", + "--text": "color", + "--muted": "color", + "--faint": "color", + "--border": "color", + "--border-strong": "color", + "--accent": "color", + "--accent-hover": "color", + "--hover": "color", + "--pressed": "color", + "--selection-bg": "color", + "--scrollbar": "color", + "--scrollbar-hover": "color", + "--shadow-color": "color", + "--card-border": "color", + "--divider": "color", + "--subtle-fill": "color", + "--selected-fill": "color", + "--focus-fill": "color", + "--neutral-pill": "color", + "--neutral-pill-bg": "color", + "--font": "font", + "--font-cjk": "font" +} diff --git a/spec/contracts/theme-v0.md b/spec/contracts/theme-v0.md new file mode 100644 index 0000000..ef53637 --- /dev/null +++ b/spec/contracts/theme-v0.md @@ -0,0 +1,58 @@ +# Theme contract v0 + +Status: **provisional**. Contract identifier: `amadeus.ui.theme/v0`. +The local P1/P2 Host implementation and its acceptance evidence are reviewed +separately; this document does not claim stable conformance. + +A theme is data. It does not execute code, request Host capabilities, inject +stylesheets or alter the Host's base component identity. `base` selects the +Host's `classic` or `wallpaper-slice` styling; the qualified extension selection +is recorded separately. Title-bar style and `colorScheme` follow that base. + +`tokens` override only the 25 names in the canonical +[theme token allowlist](../../schemas/theme-tokens.v0.json). Host tooling generates +its token names from a pinned copy of this file; adding an internal CSS variable +does not automatically expose it to packages. Status +colors (`--danger*`, `--warning*`, `--success*`) are reserved. Colors accept hex, +rgb/rgba or hsl/hsla literals; fonts reference installed system families only. +Functional literals use either comma-separated components (optional fourth alpha) +or three space-separated components followed by optional `/ alpha`. The forms +cannot be mixed; legacy comma RGB components are all numbers or all percentages. +Shared accepted/rejected cases live in `tests/fixtures/theme-color-literals.json`. +URLs, variables, declarations, comments and executable CSS are rejected. The +reference validator checks the allowlist, literal syntax/ranges, single-family +font syntax, contained image paths, size and file signatures. Installed fonts, +full native image decoding and rendered palette validation remain Host checks. +The Host validates and renders the same final palette. Normal text must retain +contrast of at least 4.5 and muted/faint text at least 3 on its actual surfaces. +Status and accent foreground text must also be checked on the surfaces where +it is displayed. Palettes keep the light/dark identity of their base; surface +colors are opaque to bound nested compositing. The Host provides a native or +otherwise theme-independent way to restore a built-in theme, even if a selected +font renders unreadable glyphs. + +An optional `background` supplies a package-relative PNG or JPEG image, +`fit` (`cover` or `contain`) and black-overlay `dim` from 0 to 1. WebP is excluded +from v0 because the current native Host decoder cannot validate it. Renaming a +WebP file does not make it a PNG/JPEG. The Host checks decoded dimensions +(each at most 4096), size (8 MiB), path containment and absence +of links. Image contrast is evaluated over the entire possible luminance range +after dim/surface compositing, including interior minima. Looking only at a +black and white endpoint is insufficient when text lies between them. +Host-validated image bytes cross into the renderer; package filesystem URLs do +not. Old object URLs/styles are released on switching, disabling or removal. + +New `runtime.kind=none` wheels cannot declare dependencies; the inspected package +resources are extracted without a virtual environment, pip or package imports. +The existing wheel inspection and ownership rules still apply. + +Pure frontend packages apply immediately. If either the old or new package has +backend contributions, its version/configuration changes apply together after +backend restart. Frontend resources use the actual loaded snapshot, never new +desired metadata with old code. Disabling/removing closes frontend admission +immediately; the interface must still disclose a backend awaiting shutdown. + +When a selected theme becomes unavailable, keep its saved identity and report +the reason while falling back to its last verified base (otherwise the Host +default). Reinstalling never silently enables a package. Resource cleanup follows +actual references and preserves retryable ownership after a deletion failure. diff --git a/spec/extension-protocol-v0.1.md b/spec/extension-protocol-v0.1.md index 583ddca..51a1a18 100644 --- a/spec/extension-protocol-v0.1.md +++ b/spec/extension-protocol-v0.1.md @@ -19,6 +19,7 @@ Companion documents: | [Voice backend contracts](contracts/voice-v0.md) (`amadeus.voice.tts/v0`, `amadeus.voice.asr/v0`) | TTS and ASR provisional | | [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 | | [Reference manifest validator](../tools/validate_manifest.py) | Informative | | [Example packages](../examples/) | Informative | @@ -75,21 +76,29 @@ uses them and passes the acceptance checks listed with each pilot: | A memory recall extension | `context_source`, scoped source access and memory governance; `observer` only when needed | A late result never delays a turn and is recorded as dropped. Forgotten items never return. Uninstalling handles data as the user chose, and earlier provenance shows the source as removed. | | A task companion | `observer`, `presentation:notify` | A revoked capability stops the next delivery or call, reports the capability name, and affects no other extension. | -### 1.3.1 Amadeus experimental restart profile (informative) +### 1.3.1 Amadeus experimental Host profile (informative) -The 2026-10-11 experimental branch implements a local-wheel subset with three -native owners: TTS (MiMo English and Fish Audio Japanese/English) and declarative +The 2026-10-12 local pilot implements a local-wheel subset with backend +owners: TTS (MiMo English and Fish Audio Japanese/English) and declarative MCP (a package-owned Notes service bound to compatible Work Providers), and Agent Skills (one installed package bound to selected Codex, ACP or Pi Agents). +A data-only theme owner applies literal palettes and bounded package images in +the desktop. The theme contract remains provisional pending pilot acceptance. MiMo does not support Japanese, confirmed by the project owner; no Japanese MiMo audition is pending. Real MiMo English acceptance awaits a configured key. -All install, configuration, enable/disable, selection and removal changes apply -after backend restart, including built-in/extension transitions. A stable TTS +Backend install, configuration, enable/disable, selection and removal changes +apply after backend restart, including built-in/extension TTS transitions. A stable TTS proxy lives for the backend process. Runner crash recovery occurs inside it and never replays a failed synthesis. The pipeline does not hot-swap runtimes. -For this profile, manifest live/reactivate labels do not override restart -semantics: saved values are delivered in the next initialize snapshot. +Manifest live/reactivate labels do not override backend restart semantics: +saved values are delivered in the next initialize snapshot. Pure frontend theme +packages apply without restarting the backend. A version switch involving an +old or new backend contribution uses the backend boundary for the whole package; +frontend contributions keep the actual loaded snapshot until then. Disabling +or removing immediately closes frontend admission; a still-running backend is +shown separately. Re-enabling a mixed package requires restart before reopening +its frontend contributions. General live configuration/generation handover below remains future design, not a requirement of this experimental subset. @@ -322,6 +331,7 @@ Kinds in v0.1: | `voice_backend` | TTS and ASR provisional | yes | `amadeus.voice.tts/v0`, `amadeus.voice.asr/v0` | TTS and ASR registries | [voice-v0](contracts/voice-v0.md) | | `skill` | provisional | no | `amadeus.skill/v0` | Work Provider skill projection | [§3.6.1](#361-skill) | | `mcp_server` | provisional | no | `amadeus.mcp-server/v0` | MCP connection registry | [§3.6.2](#362-mcp_server) | +| `theme` | provisional | no | `amadeus.ui.theme/v0` | Desktop theme owner; not projected into Work execution | [Theme contract](contracts/theme-v0.md) | | `context_source` | provisional | yes | `amadeus.context.source/v0` | Main Chat context assembly | [context-source-v0](contracts/context-source-v0.md) | | `observer` | provisional | yes | `amadeus.events.observer/v0` | Host event projection | [observer-v0](contracts/observer-v0.md) | @@ -497,6 +507,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. | | P3 | Each `${package}/…` command exists inside the package without passing through links. | ## 4. Lifecycle diff --git a/tests/fixtures/theme-color-literals.json b/tests/fixtures/theme-color-literals.json new file mode 100644 index 0000000..d18909f --- /dev/null +++ b/tests/fixtures/theme-color-literals.json @@ -0,0 +1,4 @@ +{ + "accepted": ["#123", "#1234", "#123456", "#12345678", "rgb(1, 2, 3)", "rgba(1, 2, 3, .5)", "rgb(10%,20%,30%)", "rgb(1 2 3)", "rgba(1 2 3 / 50%)", "rgb(10% 20 30% / .5)", "hsl(-30deg,80%,20%)", "hsla(360, 50%, 20%, 0.5)", "hsl(-30deg 80% 20% / 50%)"], + "rejected": ["rgb(1/2/3)", "rgb(10, 20 30)", "rgb(1,2,3 / .5)", "rgb(1 2 3 .5)", "rgb(1 2 3 / .5 / .4)", "rgb(1 2 3 /)", "rgb(10%,20,30%)", "rgba(1,,2,3)", "hsl(10,20% 30%)", "hsl(10/20%/30%)", "hsl(10 20 30)", "rgb(256 0 0)", "rgba(1,2,3,1.1)", "red", "image-set(x.png)"] +} diff --git a/tests/test_manifests.py b/tests/test_manifests.py index 81588fd..a984a7f 100644 --- a/tests/test_manifests.py +++ b/tests/test_manifests.py @@ -70,7 +70,7 @@ def test_examples_cover_every_contribution_kind(): kinds = set() for package in EXAMPLES: kinds.update(example(package.name)["contributes"]) - assert kinds == {"voice_backend", "skill", "mcp_server", "context_source", "observer"} + assert kinds == set(json.loads(vm.SCHEMA_PATH.read_text(encoding='utf-8'))['$defs']['contributes']['properties']) SCHEMA_REJECTIONS = { @@ -271,3 +271,68 @@ def test_command_line_exit_status(tmp_path, capsys): broken.write_text(json.dumps({"schema": "amadeus.extension/v0"}), encoding="utf-8") assert vm.main([str(broken)]) == 1 assert "invalid" in capsys.readouterr().out + + +@pytest.mark.parametrize('name,value,code', [ + ('--danger', '#111111', 'T1.reserved_token'), + ('--bg', 'url(https://example.invalid)', 'T2.css_input'), + ('--font', 'Arial; color:red', 'T2.css_input'), +]) +def test_theme_cannot_override_status_or_inject_css(name, value, code): + manifest = example('desktop-theme') + manifest['contributes']['theme'][0]['tokens'][name] = value + assert code in codes(manifest) + + +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'} + (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') + assert not vm.package_issues(manifest, tmp_path) + manifest['contributes']['theme'][0]['background']['image'] = '../bg.png' + assert 'schema' in codes(manifest) + + +@pytest.mark.parametrize('name,value', [ + ('--unknown', '#123456'), ('--bg', 'image-set("x.png" 1x)'), + ('--bg', 'expression(alert(1))'), ('--bg', 'red'), ('--bg', 'rgb(256 0 0)'), + ('--bg', 'hsl(10 101% 20%)'), ('--bg', '#12345'), ('--bg', 'rgba(1,2,3,1.1)'), + ('--font', 'Arial, serif'), ('--font', ''), +]) +def test_theme_rejects_unknown_tokens_and_non_literal_values(name, value): + manifest = example('desktop-theme') + manifest['contributes']['theme'][0]['tokens'][name] = value + assert codes(manifest) + + +@pytest.mark.parametrize('color', ['#123', '#1234', '#123456', '#12345678', + 'rgb(10 20 30 / 50%)', 'rgba(10, 20, 30, .5)', 'hsl(-30deg 80% 20%)']) +def test_theme_color_literals_match_host_grammar(color): + manifest=example('desktop-theme') + manifest['contributes']['theme'][0]['tokens']['--bg']=color + assert not codes(manifest) + + +@pytest.mark.parametrize('font', ['"Segoe UI"', "'Noto Sans'", '微软雅黑']) +def test_single_quoted_or_unicode_font_family_syntax(font): + manifest = example('desktop-theme') + manifest['contributes']['theme'][0]['tokens']['--font'] = font + assert not codes(manifest) + +@pytest.mark.parametrize('group', ['accepted', 'rejected']) +def test_shared_css_literal_cases(group): + cases = json.loads((ROOT / 'tests/fixtures/theme-color-literals.json').read_text(encoding='utf-8-sig')) + for color in cases[group]: + assert vm._theme_color_literal(color) is (group == 'accepted'), color + + +def test_theme_v0_rejects_webp_declaration_and_bytes(tmp_path): + manifest = example('desktop-theme') + manifest['contributes']['theme'][0]['background'] = {'image': 'bg.webp', 'fit': 'cover', 'dim': .4} + (tmp_path / 'bg.webp').write_bytes(b'RIFF' + b'\0' * 4 + b'WEBP' + b'fixture') + assert codes(manifest) + assert 'P4.theme_image' in {issue.code for issue in vm.package_issues(manifest, tmp_path)} diff --git a/tools/validate_manifest.py b/tools/validate_manifest.py index 481dde1..7e83752 100644 --- a/tools/validate_manifest.py +++ b/tools/validate_manifest.py @@ -16,9 +16,11 @@ from __future__ import annotations import json +import math import os import re import sys +import unicodedata from dataclasses import dataclass from functools import lru_cache from pathlib import Path @@ -30,6 +32,7 @@ ROOT = Path(__file__).resolve().parents[1] 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" MANIFEST_LIMIT_BYTES = 256 * 1024 SKILL_LIMIT_BYTES = 16 * 1024 * 1024 @@ -84,6 +87,56 @@ def schema_issues(manifest: Any) -> list[Issue]: return sorted(issues, key=lambda issue: issue.path) +def _theme_font_literal(value: str) -> bool: + if not 1 <= len(value) <= 128 or any(unicodedata.category(char)[0] not in 'LN' and char not in " '\"-" for char in value): + return False + family = value.strip() + if len(family) >= 2 and family[0] in "'\"" and family[-1] == family[0]: + family = family[1:-1] + return bool(family) and '"' not in family and "'" not in family + + +def _theme_color_literal(value: str) -> bool: + text = value.strip().lower() + if re.fullmatch(r'#[a-f0-9]{3}(?:[a-f0-9]|[a-f0-9]{3}|[a-f0-9]{5})?', text): + return True + match = re.fullmatch(r'(rgb|rgba|hsl|hsla)\(([^()]*)\)', text) + if not match: + return False + body = match[2].strip() + if ',' in body: + if '/' in body: + return False + parts = [part.strip() for part in body.split(',')] + if match[1].startswith('rgb') and len({part.endswith('%') for part in parts[:3]}) != 1: + return False + else: + groups = body.split('/') + if len(groups) > 2: + return False + parts = groups[0].split() + if len(parts) != 3: + return False + if len(groups) == 2: + parts.append(groups[1].strip()) + if len(parts) not in (3, 4): + return False + + def component(token: str, maximum: float, percent: bool = False) -> bool: + number = re.fullmatch(r'([+-]?(?:\d+(?:\.\d*)?|\.\d+))(%?)', token) + if not number or percent and number[2] != '%': + return False + numeric = float(number[1]) + return math.isfinite(numeric) and 0 <= numeric <= (100 if number[2] else maximum) + + if len(parts) == 4 and not component(parts[3], 1): + return False + if match[1].startswith('rgb'): + return all(component(token, 255) for token in parts[:3]) + hue = re.fullmatch(r'([+-]?(?:\d+(?:\.\d*)?|\.\d+))(?:deg)?', parts[0]) + return bool(hue and math.isfinite(float(hue[1])) and all(component(token, 1, True) for token in parts[1:3])) + + def semantic_issues(manifest: dict[str, Any]) -> list[Issue]: """Rules S1-S9 of spec section 3.9. Requires a schema-valid manifest.""" @@ -118,6 +171,20 @@ def semantic_issues(manifest: dict[str, Any]) -> list[Issue]: "an entry is allowed only when the package contributes a runner kind", )) + 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'}: + issues.append(Issue('P4.theme_image', f'/contributes/theme/{index}/background/image', 'Theme v0 images must be PNG/JPEG')) + for name, value in theme['tokens'].items(): + pointer = f'/contributes/theme/{index}/tokens/{name}' + kind = THEME_TOKENS.get(name) + if kind is None: + code = 'T1.reserved_token' if name.startswith(('--danger', '--warning', '--success')) else 'T1.unknown_token' + issues.append(Issue(code, pointer, 'Use only the published theme token allowlist')) + elif (kind == 'font' and not _theme_font_literal(value) + or kind == 'color' and not _theme_color_literal(value)): + issues.append(Issue('T2.css_input', pointer, 'Use a color literal or a single installed font family')) + secret_keys = {key for key, field in config.items() if field.get("secret")} disclosed = set(permissions.get("os", {}).get("process", [])) python_runtime = runtime["kind"] == "python" @@ -288,6 +355,23 @@ 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"])) + 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')) for index, server in enumerate(manifest["contributes"].get("mcp_server", [])): command = server.get("command", "") if server["transport"] != "stdio" or not command.startswith(_PACKAGE_PREFIX):